diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a10f6a..6d564f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: npm diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7925781..0fa0033 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: npm diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4020553..64745f5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: 'npm' diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml index cf79286..9bdfadf 100644 --- a/.github/workflows/external-smoke.yml +++ b/.github/workflows/external-smoke.yml @@ -56,7 +56,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: npm diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4b9ff3b..504e765 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/sync-worker-secrets.yml b/.github/workflows/sync-worker-secrets.yml index 436f500..3466bd4 100644 --- a/.github/workflows/sync-worker-secrets.yml +++ b/.github/workflows/sync-worker-secrets.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: npm diff --git a/.github/workflows/workers-invocations-chart.yml b/.github/workflows/workers-invocations-chart.yml index cbe7e92..5f84625 100644 --- a/.github/workflows/workers-invocations-chart.yml +++ b/.github/workflows/workers-invocations-chart.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: npm diff --git a/.nvmrc b/.nvmrc index 209e3ef..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a36d3a4..87206cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ ## 사전 준비 -- Node.js: `20.x` (`.nvmrc` 참고) -- npm: Node 20에 포함된 버전 사용 권장 +- Node.js: `24.x` (`.nvmrc` 참고) +- npm: `11.x` (Node.js 24에 포함된 버전 사용 권장) ```bash nvm use diff --git a/README.md b/README.md index d50f987..b5e0487 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,51 @@ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \ Better Stack 같은 외부 모니터링에서는 `fail`을 장애 알림 기준으로 보고, `degraded`는 느린 외부 API나 응답 품질 저하를 추적하는 경고 신호로 봅니다. +### 운영 통계 + +일일 호출 제한으로 차단한 요청의 집계는 인증된 `GET /api/rate-limit/stats`에서 조회합니다. 헬스 체크와 같은 `HEALTH_CHECK_SECRET`을 사용하며, 다음 두 인증 헤더를 모두 지원합니다. + +```bash +curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \ + "https://mcp.aka.page/api/rate-limit/stats" + +curl -H "x-health-check-key: $HEALTH_CHECK_SECRET" \ + "https://mcp.aka.page/api/rate-limit/stats?service=cgv" +``` + +쿼리 필터는 `from`, `to`, `service`입니다. `from`과 `to`는 함께 지정하거나 둘 다 생략해야 하며 날짜 형식은 `YYYY-MM-DD`입니다. `service`에는 `oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`만 사용할 수 있습니다. 날짜를 생략하면 현재 KST 일자를 포함한 최근 7일을 조회합니다. 조회 가능한 보관 범위는 현재 KST 일자와 그 이전 29일이며, 한 번에 KST 달력 날짜 기준 최대 30일을 요청할 수 있습니다. + +성공 응답은 전체 합계와 일별·서비스별 차단 요청 수와 고유 차단 주체 수를 제공합니다. + +```json +{ + "success": true, + "data": { + "totals": { + "blockedRequests": 3, + "uniqueIdentities": 2 + }, + "daily": [ + { + "day": "2026-07-22", + "blockedRequests": 3, + "uniqueIdentities": 2 + } + ], + "services": [ + { + "day": "2026-07-22", + "service": "cgv", + "blockedRequests": 3, + "uniqueIdentities": 2 + } + ] + } +} +``` + +데이터는 30일 동안 보관하며 집계 응답은 원본 호출 주체나 IP를 노출하지 않습니다. Worker가 생성한 `DAILY_RATE_LIMIT_EXCEEDED` 결정 중 원장 커밋에 성공한 경우만 정확한 집계 범위에 포함됩니다. Cloudflare 또는 네트워크 계층의 429와 클라이언트 전송 결과, 연결 종료 결과는 이 범위에 포함되지 않습니다. 원장 쓰기에 실패하면 요청을 fail-open 처리하고 애플리케이션 429를 반환하지 않습니다. 통계는 이 기능의 배포 시점부터 수집하며 이전 429는 소급 집계하지 않습니다. + 배포 전 로컬에서 CLI 모드까지 확인할 때는 아래 명령을 사용합니다. ```bash diff --git a/biome.jsonc b/biome.jsonc index 4063a83..d82154a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/service-reference.md b/docs/service-reference.md index 987ace7..4cec3b2 100644 --- a/docs/service-reference.md +++ b/docs/service-reference.md @@ -425,6 +425,13 @@ MCP를 지원하지 않는 서비스를 위한 GET 기반 REST API입니다. | `GET /api/cgv/theaters` | CGV 극장 목록 조회 | | `GET /api/cgv/movies` | CGV 영화 목록 조회 | | `GET /api/cgv/timetable` | CGV 상영 시간표 조회 | +| `GET /api/rate-limit/stats` | 인증된 일일 호출 제한 운영 통계 | + +### 호출 제한 운영 통계 + +`GET /api/rate-limit/stats` 요청은 `HEALTH_CHECK_SECRET`을 `Authorization: Bearer` 또는 `x-health-check-key` 헤더로 전달해 인증합니다. `from`과 `to`는 둘 다 생략하거나 함께 제공해야 합니다. `service`에는 `oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`만 사용할 수 있습니다. 날짜를 생략하면 현재 KST 일자를 포함한 최근 7일을 조회합니다. 차단 이벤트는 30일 동안 보관합니다. 보관 범위 안에서 KST 달력 날짜 기준 최대 30일을 요청할 수 있습니다. 응답은 전체, 일별, 서비스별 차단 요청 수와 고유 차단 주체 수 집계만 반환하며 원본 호출 주체나 IP를 노출하지 않습니다. + +Worker가 생성한 `DAILY_RATE_LIMIT_EXCEEDED` 결정 중 원장 커밋에 성공한 경우만 정확한 집계 범위에 포함됩니다. Cloudflare 또는 네트워크 계층의 429와 클라이언트 전송 결과, 연결 종료 결과는 이 범위에 포함되지 않습니다. 원장 쓰기에 실패하면 요청을 fail-open 처리하고 애플리케이션 429를 반환하지 않습니다. 통계는 이 기능의 배포 시점부터 수집하며 이전 429는 소급 집계하지 않습니다. ### 제품 검색 @@ -500,16 +507,16 @@ GET /api/cu/stores?keyword={키워드}&lat={위도}&lng={경도} GET /api/cu/inventory?keyword={검색어}&lat={위도}&lng={경도} ``` -| 파라미터 | 필수 | 설명 | -| :------------- | :--: | :----------------------------- | -| `keyword` | O | 검색 키워드 (예: 과자, 컵라면) | -| `lat` | | 위도 (기본값: 37.5665) | -| `lng` | | 경도 (기본값: 126.978) | -| `storeKeyword` | | 주변 매장 필터 키워드 | -| `size` | | 검색 결과 수 (기본값: 20) | -| `offset` | | 검색 시작 오프셋 (기본값: 0) | -| `searchSort` | | 정렬 방식 (기본값: `recom`) | -| `storeLimit` | | 매장 결과 수 (기본값: 10) | +| 파라미터 | 필수 | 설명 | +| :------------- | :--: | :------------------------------ | +| `keyword` | O | 검색 키워드 (예: 과자, 컵라면) | +| `lat` | | 위도 (기본값: 37.5665) | +| `lng` | | 경도 (기본값: 126.978) | +| `storeKeyword` | | 주변 매장 필터 키워드 | +| `size` | | 검색 결과 수 (기본값: 20) | +| `offset` | | 검색 시작 오프셋 (기본값: 0) | +| `searchSort` | | 정렬 방식 (기본값: `recom`) | +| `storeLimit` | | 매장 결과 수 (기본값: 10) | | `storeCheck` | | `false`이면 주변 매장 조회 생략 | ### 올리브영 재고 확인 @@ -863,9 +870,7 @@ class CuService implements ServiceProvider { }; getTools() { - return [ - /* cu_search_products, cu_find_stores 등 */ - ]; + return [/* cu_search_products, cu_find_stores 등 */]; } } diff --git a/docs/superpowers/plans/2026-07-22-exact-rate-limit-metrics-and-dependencies.md b/docs/superpowers/plans/2026-07-22-exact-rate-limit-metrics-and-dependencies.md new file mode 100644 index 0000000..a3368a5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-exact-rate-limit-metrics-and-dependencies.md @@ -0,0 +1,458 @@ +# Exact Rate Limit Metrics and Dependencies Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Record every Worker-generated daily-limit 429 in an exact private ledger, expose authenticated aggregate statistics, clear compatible Dependabot and npm audit findings, and deploy the verified release. + +**Architecture:** Keep the existing per-identity quota Durable Objects and reserve one object in the same namespace as the blocked-event ledger. A denied request writes synchronously to the ledger before the middleware emits 429; ledger failure fails open. A dedicated authenticated route queries aggregate-only 30-day statistics, while dependency changes are isolated in later commits and verified against the complete test suite. + +**Tech Stack:** TypeScript, Hono, Cloudflare Workers and SQLite Durable Objects, Vitest with V8 coverage, npm, GitHub Actions, Wrangler. + +--- + +### Task 1: Extract shared operational authentication + +**Files:** + +- Create: `src/api/operationalAuth.ts` +- Modify: `src/api/routes/healthRoutes.ts` +- Test: `tests/app/app-health-checks.test.ts` + +- [ ] **Step 1: Write the failing authentication unit tests** + +Create `tests/api/operationalAuth.test.ts` with direct coverage for Bearer priority, `x-health-check-key` fallback, missing secret, valid secret, and invalid secret: + +```ts +expect(readOperationalToken(new Headers({ Authorization: 'Bearer test-secret' }))).toBe( + 'test-secret', +); +expect(readOperationalToken(new Headers({ 'x-health-check-key': 'test-secret' }))).toBe( + 'test-secret', +); +expect(authorizeOperationalRequest(new Headers(), undefined)).toBe('not-configured'); +expect( + authorizeOperationalRequest(new Headers({ Authorization: 'Bearer test-secret' }), 'test-secret'), +).toBe('authorized'); +expect(authorizeOperationalRequest(new Headers(), 'test-secret')).toBe('unauthorized'); +``` + +- [ ] **Step 2: Verify the tests fail because the module is absent** + +Run: `npx vitest run tests/api/operationalAuth.test.ts` + +Expected: FAIL with an unresolved `src/api/operationalAuth.js` import. + +- [ ] **Step 3: Implement the shared helper and adopt it in health routes** + +Export these interfaces from `src/api/operationalAuth.ts`: + +```ts +export type OperationalAuthorization = 'authorized' | 'unauthorized' | 'not-configured'; + +export function readOperationalToken(headers: Headers): string { + const authorization = headers.get('Authorization') || ''; + if (authorization.toLowerCase().startsWith('bearer ')) { + return authorization.slice('bearer '.length).trim(); + } + return (headers.get('x-health-check-key') || '').trim(); +} + +export function authorizeOperationalRequest( + headers: Headers, + configuredSecret: string | undefined, +): OperationalAuthorization { + const secret = configuredSecret?.trim(); + if (!secret) return 'not-configured'; + return readOperationalToken(headers) === secret ? 'authorized' : 'unauthorized'; +} +``` + +Replace the private token reader in `healthRoutes.ts` with this helper while preserving the existing 503 and 401 error codes and messages. + +- [ ] **Step 4: Run unit and health regressions** + +Run: `npx vitest run tests/api/operationalAuth.test.ts tests/app/app-health-checks.test.ts` + +Expected: both files PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/operationalAuth.ts src/api/routes/healthRoutes.ts tests/api/operationalAuth.test.ts tests/app/app-health-checks.test.ts +git commit -m "refactor: 운영 API 인증 로직 공통화" +``` + +### Task 2: Build the exact blocked-event ledger + +**Files:** + +- Create: `src/durableObjects/rateLimitMetricsStore.ts` +- Create: `tests/durableObjects/rateLimitMetricsStore.test.ts` + +- [ ] **Step 1: Write failing store tests around an in-memory SQL fixture** + +Cover schema initialization, idempotent `event_id`, repeated identities, per-service grouping, KST date validation, 30-day cleanup, alarm scheduling, and aggregate-only output. Use this public shape: + +```ts +const store = new RateLimitMetricsStore(state); +await store.record({ + eventId: 'event-1', + occurredAt: Date.parse('2026-07-22T00:00:00Z'), + day: '2026-07-22', + service: 'cgv', + identityId: 'opaque-id-1', +}); +const stats = await store.query({ from: '2026-07-22', to: '2026-07-22' }); +expect(stats.totals).toEqual({ blockedRequests: 1, uniqueIdentities: 1 }); +expect(JSON.stringify(stats)).not.toContain('opaque-id-1'); +``` + +Record `event-1` twice and assert `blockedRequests` remains `1`. Record the same identity for `cgv` and `gs25` and assert the global unique count remains `1` while both service rows report one unique identity. + +- [ ] **Step 2: Verify the tests fail because the store is absent** + +Run: `npx vitest run tests/durableObjects/rateLimitMetricsStore.test.ts` + +Expected: FAIL with an unresolved store import. + +- [ ] **Step 3: Implement the SQLite store** + +Implement these exported types and methods: + +```ts +export const RATE_LIMIT_METRICS_RETENTION_DAYS = 30; +export const RATE_LIMIT_METRICS_LEDGER_NAME = '__blocked-ledger-v1__'; +export const RATE_LIMIT_SERVICES = ['oliveyoung', 'cgv', 'cu', 'gs25', 'lottemart'] as const; +export type RateLimitService = (typeof RATE_LIMIT_SERVICES)[number]; + +export interface BlockedRateLimitEvent { + eventId: string; + occurredAt: number; + day: string; + service: RateLimitService; + identityId: string; +} + +export class RateLimitMetricsStore { + constructor(private readonly state: DurableObjectState) {} + record(event: BlockedRateLimitEvent): Promise; + query(input: { from: string; to: string; service?: RateLimitService }): Promise; + cleanup(nowMs?: number): Promise; + ensureAlarm(nowMs?: number): Promise; +} +``` + +Create `blocked_events`, three indexes, `INSERT OR IGNORE`, `COUNT(*)`, and `COUNT(DISTINCT identity_id)` queries. Query daily/service rows in deterministic day and service order. Delete rows with `day` older than the KST retention cutoff. Schedule the next alarm for the next KST midnight plus five minutes only when no alarm is already present. + +- [ ] **Step 4: Run store tests and coverage for the new module** + +Run: `npx vitest run tests/durableObjects/rateLimitMetricsStore.test.ts --coverage` + +Expected: PASS and the new module has 100% statements, branches, functions, and lines. + +- [ ] **Step 5: Commit** + +```bash +git add src/durableObjects/rateLimitMetricsStore.ts tests/durableObjects/rateLimitMetricsStore.test.ts +git commit -m "feat: 정확한 429 원장 저장소 추가" +``` + +### Task 3: Route quota and ledger operations through the existing Durable Object + +**Files:** + +- Modify: `src/durableObjects/dailyRateLimiter.ts` +- Modify: `tests/durableObjects/dailyRateLimiter.test.ts` + +- [ ] **Step 1: Write failing route and alarm tests** + +Assert `POST /consume` returns a quota result, `POST /blocked-events` validates and records a complete event, `GET /stats` passes parsed filters to the store, unsupported method/path returns 404, malformed event returns 400, and `alarm()` invokes cleanup then schedules the next alarm. + +```ts +const response = await limiter.fetch( + new Request('https://daily-rate-limit/blocked-events', { + method: 'POST', + body: JSON.stringify(validEvent), + }), +); +expect(response.status).toBe(204); +``` + +- [ ] **Step 2: Run the DO tests and verify route assertions fail** + +Run: `npx vitest run tests/durableObjects/dailyRateLimiter.test.ts` + +Expected: FAIL because `fetch` ignores request routing and no `alarm` method exists. + +- [ ] **Step 3: Implement request routing and alarm delegation** + +Change `fetch` to accept `Request`, route only the three known operation paths, validate service/date/event fields before storage, and return JSON 400 or 404 responses without throwing. Instantiate `RateLimitMetricsStore` only for ledger/stats operations. Add: + +```ts +async alarm(): Promise { + const store = new RateLimitMetricsStore(this.state); + await store.cleanup(); + await store.ensureAlarm(); +} +``` + +- [ ] **Step 4: Run DO tests** + +Run: `npx vitest run tests/durableObjects/dailyRateLimiter.test.ts tests/durableObjects/rateLimitMetricsStore.test.ts` + +Expected: both files PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/durableObjects/dailyRateLimiter.ts tests/durableObjects/dailyRateLimiter.test.ts +git commit -m "feat: 호출 제한 객체에 429 원장 라우팅 추가" +``` + +### Task 4: Gate every application 429 on a committed ledger event + +**Files:** + +- Modify: `src/middleware/dailyRateLimit.ts` +- Modify: `tests/middleware/dailyRateLimit.test.ts` +- Modify: `tests/app/app-daily-rate-limit.test.ts` + +- [ ] **Step 1: Write failing middleware and integration tests** + +Update the Durable Object fixture so `idFromName` returns an ID with stable `toString()`. Assert an allowed request calls only `/consume`; a denied request calls `/consume` and then the reserved ledger `/blocked-events`; the ledger body has a UUID, KST day, service, timestamp, and opaque ID but contains neither `203.0.113.10` nor its SHA-256 value. Assert ledger 503/throw results in the downstream handler response rather than 429. + +```ts +expect(calls.map((call) => new URL(call.request.url).pathname)).toEqual([ + '/consume', + '/blocked-events', +]); +expect(response.status).toBe(429); +expect(recordedBody.service).toBe('cgv'); +``` + +- [ ] **Step 2: Verify the new tests fail against the current middleware** + +Run: `npx vitest run tests/middleware/dailyRateLimit.test.ts tests/app/app-daily-rate-limit.test.ts` + +Expected: FAIL because no ledger call is made and ledger failure cannot fail open. + +- [ ] **Step 3: Implement service-aware consumption and synchronous recording** + +Replace prefix-only matching with descriptors that map prefixes to `RateLimitService`. Return an internal decision containing the quota result, service, and the resolved Durable Object ID. On denial, build a UUID event and synchronously call `idFromName(RATE_LIMIT_METRICS_LEDGER_NAME)` at `/blocked-events`. Only return the 429 when that response is 204. Log a non-sensitive error and call `next()` on any ledger failure. + +- [ ] **Step 4: Run middleware and app rate-limit tests** + +Run: `npx vitest run tests/middleware/dailyRateLimit.test.ts tests/app/app-daily-rate-limit.test.ts` + +Expected: both files PASS, including the exact 1:1 and fail-open assertions. + +- [ ] **Step 5: Commit** + +```bash +git add src/middleware/dailyRateLimit.ts tests/middleware/dailyRateLimit.test.ts tests/app/app-daily-rate-limit.test.ts +git commit -m "feat: 429 응답을 정확한 원장 기록과 연결" +``` + +### Task 5: Expose authenticated aggregate rate-limit statistics + +**Files:** + +- Create: `src/api/routes/rateLimitStatsRoutes.ts` +- Create: `tests/app/app-rate-limit-stats.test.ts` +- Modify: `src/index.ts` +- Modify: `src/api/response.ts` only if a shared response type requires it + +- [ ] **Step 1: Write failing API tests** + +Test missing `HEALTH_CHECK_SECRET` gives 503, invalid credentials give 401, invalid dates/service or a range over 30 days give 400, a valid request fetches the reserved object `/stats`, and a backend error gives 503. Assert the successful body contains totals and rows but no `identityId` or raw IP. + +```ts +const response = await app.request( + '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22&service=cgv', + { headers: { Authorization: 'Bearer test-secret' } }, + env, +); +expect(response.status).toBe(200); +expect(await response.json()).toMatchObject({ success: true, data: expectedStats }); +``` + +- [ ] **Step 2: Verify the route tests fail with 404** + +Run: `npx vitest run tests/app/app-rate-limit-stats.test.ts` + +Expected: FAIL because the route is not registered. + +- [ ] **Step 3: Implement validation, authentication, and aggregate proxying** + +Register `GET /api/rate-limit/stats`. Reuse `authorizeOperationalRequest`, default to the last seven KST days, enforce `from <= to` and a 30-day inclusive maximum, validate service against `RATE_LIMIT_SERVICES`, query the reserved object, and return `successResponse`. Map configuration/authentication/validation/backend failures to stable 503/401/400/503 codes. + +Register the route next to health routes in `src/index.ts`. If `src/index.ts` would exceed 450 lines, move root-info endpoint metadata to `src/api/rootInfo.ts` before adding the new registration. + +- [ ] **Step 4: Run stats, health, and rate-limit integration tests** + +Run: `npx vitest run tests/app/app-rate-limit-stats.test.ts tests/app/app-health-checks.test.ts tests/app/app-daily-rate-limit.test.ts` + +Expected: all files PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/routes/rateLimitStatsRoutes.ts tests/app/app-rate-limit-stats.test.ts src/index.ts src/api/response.ts src/api/rootInfo.ts +git commit -m "feat: 인증된 429 통계 API 추가" +``` + +Only add paths that exist to the commit. + +### Task 6: Document the operational metric contract + +**Files:** + +- Modify: `README.md` +- Modify: `docs/service-reference.md` if it contains the operational endpoint catalog + +- [ ] **Step 1: Add documentation assertions** + +Extend the existing README/repository configuration test to require `/api/rate-limit/stats`, `HEALTH_CHECK_SECRET`, 30-day retention, the Worker-generated 429 exactness boundary, and the statement that collection begins at deployment. + +- [ ] **Step 2: Run the documentation test and verify it fails** + +Run: `npx vitest run tests/scripts/repository-config.test.ts` + +Expected: FAIL because the endpoint contract is not documented. + +- [ ] **Step 3: Add the authenticated endpoint documentation** + +Document Bearer and `x-health-check-key` examples, query parameters, aggregate-only response fields, 30-day retention, fail-open behavior, and non-retroactive collection. Preserve the existing GitHub Important callout for the daily 3,000-search policy. + +- [ ] **Step 4: Run the documentation test and formatting check** + +Run: `npx vitest run tests/scripts/repository-config.test.ts` + +Run: `npx prettier README.md docs/service-reference.md --check` + +Expected: tests and formatting PASS. + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/service-reference.md tests/scripts/repository-config.test.ts +git commit -m "docs: 정확한 429 통계 조회 방법 추가" +``` + +Only add `docs/service-reference.md` if it changed. + +### Task 7: Resolve compatible Dependabot and npm audit findings + +**Files:** + +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `biome.jsonc` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/coverage.yml` +- Modify: `.github/workflows/deploy.yml` +- Modify: `.github/workflows/external-smoke.yml` +- Modify: `.github/workflows/npm-publish.yml` +- Modify: `.github/workflows/sync-worker-secrets.yml` +- Modify: `.github/workflows/workers-invocations-chart.yml` +- Modify: `tests/scripts/repository-config.test.ts` + +- [ ] **Step 1: Capture the authoritative current update and audit set** + +Run: `npm outdated --json` + +Run: `npm audit --json` + +Run: `gh pr list --state open --author app/dependabot --json number,title,headRefName,statusCheckRollup` + +Expected: eight compatible updates, TypeScript 7 blocked by the `typescript-eslint` peer range, and the currently reported direct/transitive audit paths. + +- [ ] **Step 2: Update compatible package families and security paths** + +Use exact compatible latest versions returned by the registry for Hono, `typescript-eslint`, Workers types, `tsx`, Biome, Wrangler, and Prettier while preserving TypeScript 6.0.3. Update the Biome schema URL to the installed CLI version. Upgrade direct parents where possible; use narrow overrides only for unresolved safe transitive fixes such as `brace-expansion` and `body-parser`. + +Regenerate with: `npm install` + +Expected: `npm ls` exits 0 without invalid peer dependencies. + +- [ ] **Step 3: Update GitHub Actions setup-node references** + +Replace all seven `actions/setup-node@v6` references with `actions/setup-node@v7`. Extend `tests/scripts/repository-config.test.ts` to assert there are no v6 references and each expected workflow contains v7. + +- [ ] **Step 4: Verify the integrated dependency graph and security state** + +Run: `npm ci` + +Run: `npm ls` + +Run: `npm audit` + +Run: `npm run check` + +Run: `npm run test:coverage` + +Run: `npm run build` + +Expected: install and dependency tree succeed; audit has zero actionable vulnerabilities; 100% coverage; check and build PASS. If a current upstream package has no compatible security release, preserve the supported graph and document the exact advisory, dependency path, and upstream version evidence before proceeding. + +- [ ] **Step 5: Commit package and workflow changes separately** + +```bash +git add package.json package-lock.json biome.jsonc +git commit -m "chore: 의존성과 보안 패키지 업데이트" + +git add .github/workflows tests/scripts/repository-config.test.ts +git commit -m "ci: setup-node v7으로 업데이트" +``` + +### Task 8: Review, integrate, deploy, and close superseded Dependabot PRs + +**Files:** + +- Modify: `projects/daiso-mcp/PROJECT.md` in the parent workspace after repository integration + +- [ ] **Step 1: Run final verification from a clean install** + +Run: `git diff origin/main...HEAD --check` + +Run: `npm ci` + +Run: `npm run check` + +Run: `npm run test:coverage` + +Run: `npm run build` + +Run: `npm audit` + +Run: `find src tests scripts -name '*.ts' -print0 | xargs -0 wc -l | sort -nr | head -20` + +Expected: all commands PASS, coverage is 100%, audit is clear or has an explicitly evidenced upstream-only exception, and modified code files stay at or below 450 lines. + +- [ ] **Step 2: Perform correctness and simplification reviews** + +Inspect the full branch diff for exactness, privacy, authentication, fail-open behavior, retention, dependency compatibility, dead abstractions, and unrelated changes. Fix every confirmed finding and rerun the affected tests plus final verification. + +- [ ] **Step 3: Rebase or merge the latest origin/main safely** + +Run: `git fetch origin` + +Run: `git log --oneline --left-right HEAD...origin/main` + +Integrate upstream without discarding automatic chart updates, resolve only branch-owned conflicts, and rerun the complete verification suite. + +- [ ] **Step 4: Push and merge to main** + +Push the feature branch, create or update a PR with the exact metric contract and dependency disposition, wait for required checks, then merge using the repository's accepted method. Do not force-push main. + +- [ ] **Step 5: Close unsupported or superseded Dependabot PRs** + +Confirm compatible PRs auto-close after main contains their versions. Close TypeScript 7 PR #138 with the `typescript-eslint <6.1.0` peer-dependency evidence. Close any remaining superseded Dependabot PR with a comment pointing to the integrated commit and successful checks. + +- [ ] **Step 6: Verify deployment and the authenticated production API** + +Wait for the deploy workflow to finish successfully. Confirm the active Cloudflare Worker version matches the merged commit. Query `/health`, then query `/api/rate-limit/stats` with the configured health secret. Expect HTTP 200 and aggregate zero/nonzero data, never raw identities. Confirm an unauthenticated query returns 401. + +- [ ] **Step 7: Update project memory and send completion notification** + +Record the implementation commits, test evidence, dependency dispositions, deployed Worker version, stats endpoint behavior, and Zyte non-action in `projects/daiso-mcp/PROJECT.md`. Send the configured Moshi completion webhook only after deployment evidence is complete. diff --git a/docs/superpowers/specs/2026-07-22-exact-rate-limit-metrics-and-dependencies-design.md b/docs/superpowers/specs/2026-07-22-exact-rate-limit-metrics-and-dependencies-design.md new file mode 100644 index 0000000..e8f142a --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-exact-rate-limit-metrics-and-dependencies-design.md @@ -0,0 +1,123 @@ +# 정확한 429 계측 및 의존성 정비 설계 + +## 배경 + +다이소 MCP Worker는 검색 API에 KST 일자 기준 IP·호출 주체별 3,000회 제한을 적용한다. 현재 제한 자체는 Durable Object의 강한 일관성을 이용해 정확히 집행하지만, 제한을 초과해 반환한 429 응답은 별도로 집계하지 않는다. Cloudflare GraphQL 호출량은 수집 지연과 샘플링이 있어 정확한 차단 수를 증명할 수 없다. + +동시에 Dependabot 업데이트 PR 9건과 `npm audit` 보안 경고가 열려 있다. 호환 가능한 업데이트는 일괄 검증하되, 지원되지 않는 TypeScript 7 조합을 강제 설치하지 않아야 한다. + +Zyte 요금제 상향은 비용이 오픈소스 운영 수준에 비해 높다는 판단에 따라 이번 범위에서 제외한다. + +## 목표 + +- Worker가 생성하기로 결정한 `DAILY_RATE_LIMIT_EXCEEDED` 429 한 건마다 원장 한 건이 먼저 확정되게 한다. +- 일별·서비스별 차단 요청 수와 고유 차단 주체 수를 인증된 운영 API에서 정확히 조회한다. +- 원본 IP와 역추적에 직접 쓸 수 있는 단순 IP 해시를 원장에 보관하지 않는다. +- 계측 장애 때문에 정상 사용자를 차단하지 않는다. +- 호환 가능한 Dependabot 업데이트와 npm 보안 경고를 정리하고 전체 회귀 검증을 통과한다. +- 기존 Durable Object 바인딩과 배포 마이그레이션을 늘리지 않는다. + +## 비목표 + +- Zyte 결제 한도 또는 요금제 변경 +- 모든 Cloudflare·네트워크 계층 429 집계 +- 개별 IP나 호출 주체를 운영 API에 노출하는 기능 +- TypeScript 7을 지원 범위 밖에서 강제 설치하는 작업 + +## 정확한 429 계측 + +### 원장 구조 + +기존 `DAILY_RATE_LIMITER` 네임스페이스에 예약된 단일 Durable Object 이름 `__blocked-ledger-v1__`을 사용한다. 기존 객체는 호출 주체별 할당량을 계속 저장하고, 예약 객체만 차단 이벤트 원장을 담당한다. 같은 SQLite Durable Object 클래스를 재사용하므로 `wrangler.toml` 바인딩이나 Durable Object 마이그레이션은 추가하지 않는다. + +원장 테이블은 다음 정보를 저장한다. + +```sql +blocked_events ( + event_id TEXT PRIMARY KEY, + occurred_at INTEGER NOT NULL, + day TEXT NOT NULL, + service TEXT NOT NULL, + identity_id TEXT NOT NULL +) +``` + +- `event_id`: 재시도 중복 기록을 막는 UUID +- `occurred_at`, `day`: KST 기준 발생 시각과 날짜 +- `service`: 허용 목록으로 검증한 서비스 식별자 +- `identity_id`: 이미 할당량 객체 조회에 사용한 namespace-scoped Durable Object ID 문자열 + +`identity_id`는 운영 API 응답에 포함하지 않는다. 원본 IP와 IP의 SHA-256 문자열도 원장 요청·저장소에 전달하지 않는다. `(day, service)`, `(day, identity_id)`, `(day, service, identity_id)` 인덱스로 집계를 지원한다. + +### 요청 흐름과 정확성 경계 + +1. 보호 대상 GET 경로에서 서비스와 호출 주체를 한 번 해석한다. +2. 현재와 동일하게 호출 주체별 Durable Object에서 KST 일일 할당량을 소비한다. +3. 허용된 요청은 현재 동작을 유지한다. +4. 초과 요청이면 이벤트 UUID를 만들고 예약 원장 객체에 동기식으로 기록한다. +5. 원장 커밋 확인 후에만 애플리케이션 429를 반환한다. + +원장 기록에 실패하면 해당 요청은 fail-open 처리한다. 따라서 계측 장애가 잘못된 차단으로 이어지지 않으며, 애플리케이션이 생성하기로 확정한 모든 429에는 원장 행이 존재한다. 클라이언트 연결 종료나 Cloudflare 자체 429처럼 Worker 바깥에서 발생한 전송 결과는 이 정확성 범위에 포함하지 않는다. + +### 집계 API + +다음 인증 API를 추가한다. + +```text +GET /api/rate-limit/stats?from=YYYY-MM-DD&to=YYYY-MM-DD&service=cgv +``` + +- 기존 `HEALTH_CHECK_SECRET`을 재사용한다. +- `Authorization: Bearer`와 `x-health-check-key`를 모두 지원한다. +- 비밀값 미설정은 503, 누락·불일치 인증은 401을 반환한다. +- 기본 조회 기간은 최근 7일, 최대 범위는 보관 기간인 30일이다. +- 전체 합계와 일별·서비스별 차단 요청 수, 고유 차단 주체 수만 반환한다. +- 원장 조회 실패는 잘못된 0 대신 503을 반환한다. + +헬스체크 경로의 기존 인증 로직은 공용 운영 인증 모듈로 추출하고 응답 동작을 회귀 테스트로 보존한다. + +### 보관과 정리 + +원장 보관 기간은 30일이다. 첫 기록 시 일일 alarm을 예약하고 alarm에서 오래된 행을 삭제한다. alarm은 재시도되어도 안전한 멱등 삭제로 구현한다. alarm 지연 시 보관 기간이 무한히 늘지 않도록 기록·조회 시에도 제한적인 보조 정리를 수행한다. + +## 의존성 정비 + +### 호환 가능한 업데이트 + +현재 최신 호환 버전으로 한 번에 lockfile을 재생성하고 조합 상태를 검증한다. + +- Hono +- `typescript-eslint` +- `@cloudflare/workers-types` +- `tsx` +- Biome와 `biome.jsonc` 스키마 URL +- Wrangler +- Prettier +- GitHub Actions `setup-node` v7 + +Wrangler와 Workers 타입은 서로의 peer dependency가 맞는 조합으로 함께 올린다. `setup-node`는 Dependabot PR이 변경한 7개 워크플로에 동일하게 적용한다. + +### TypeScript 7 + +TypeScript 7 PR은 병합하지 않는다. 최신 `typescript-eslint`도 TypeScript `>=4.8.4 <6.1.0`만 지원하므로 현재 PR은 `npm ci`에서 `ERESOLVE`로 실패한다. TypeScript 6.0.3을 유지하고 PR #138에 근거를 남겨 닫는다. `--force`와 `--legacy-peer-deps`는 사용하지 않는다. + +### 보안 경고 + +`npm audit --json`의 직접·전이 경로를 확인해 호환되는 상위 패키지 업데이트 또는 최소 범위 override로 제거한다. 현재 조사 대상에는 `brace-expansion`, `body-parser`, `fast-uri`, `linkify-it`, `@hono/node-server` 및 MCP SDK 경로가 포함된다. 각 변경은 lockfile만 바꾸는 것으로 끝내지 않고 MCP·HTTP 회귀 테스트로 런타임 호환성을 확인한다. + +## 테스트 전략 + +구현은 실패하는 테스트를 먼저 추가하는 TDD 순서로 진행한다. + +- Durable Object 원장: 중복 이벤트, 반복·분리 호출 주체, 서비스 집계, KST 경계, 30일 보관, alarm 재시도 +- 미들웨어: 원장 커밋 전 429 금지, 기록 실패 fail-open, 이벤트 1회 기록, 원본 IP·단순 해시 미전달 +- 앱 통합: 생성된 429와 원장 기록의 1:1 관계, 보호·비보호 경로 회귀 +- 집계 API: 503·401·인증 성공, 기간·서비스 검증, 집계 전용 응답, 저장소 장애 503 +- 헬스체크: 공용 인증 추출 전후의 기존 동작 보존 +- 의존성: 설치, 포맷, ESLint, Biome, 타입 검사, 전체 테스트, 100% 커버리지, 빌드, npm audit + +## 배포와 운영 확인 + +기능·의존성 변경을 같은 작업 브랜치에서 검증하되 논리적인 커밋으로 분리한다. main 반영 후 CI/CD 성공과 Cloudflare 배포 버전을 확인한다. 인증된 통계 API가 빈 기간에는 정확한 0을, 장애에는 503을 반환하는지 확인하고, 실제 제한 테스트는 운영 카운터를 불필요하게 소모하지 않는 범위에서 수행한다. + +정확한 집계는 새 코드가 배포된 시점부터 시작하며 이전 429를 소급 복원하지 않는다. diff --git a/package-lock.json b/package-lock.json index 9094846..405186a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.10", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", - "hono": "^4.12.27", + "@modelcontextprotocol/sdk": "^1.29.0", + "hono": "^4.12.31", "proj4": "^2.20.9", "workers-mcp": "^0.0.13" }, @@ -18,8 +18,8 @@ "daiso": "dist/bin.js" }, "devDependencies": { - "@biomejs/biome": "^2.5.2", - "@cloudflare/workers-types": "^4.20260702.1", + "@biomejs/biome": "^2.5.5", + "@cloudflare/workers-types": "^5.20260722.1", "@eslint/js": "^10.0.1", "@types/node": "^26.1.0", "@vitest/coverage-v8": "^4.1.7", @@ -28,16 +28,16 @@ "globals": "^17.7.0", "jiti": "^2.7.0", "playwright": "^1.61.1", - "prettier": "^3.9.4", + "prettier": "^3.9.6", "sharp": "^0.35.3", - "tsx": "^4.23.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.1", + "tsx": "^4.23.1", + "typescript": "6.0.3", + "typescript-eslint": "^8.65.0", "vitest": "^4.1.7", - "wrangler": "^4.107.0" + "wrangler": "^4.113.0" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@babel/helper-string-parser": { @@ -97,9 +97,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.3.tgz", - "integrity": "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.5.tgz", + "integrity": "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -113,20 +113,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.3", - "@biomejs/cli-darwin-x64": "2.5.3", - "@biomejs/cli-linux-arm64": "2.5.3", - "@biomejs/cli-linux-arm64-musl": "2.5.3", - "@biomejs/cli-linux-x64": "2.5.3", - "@biomejs/cli-linux-x64-musl": "2.5.3", - "@biomejs/cli-win32-arm64": "2.5.3", - "@biomejs/cli-win32-x64": "2.5.3" + "@biomejs/cli-darwin-arm64": "2.5.5", + "@biomejs/cli-darwin-x64": "2.5.5", + "@biomejs/cli-linux-arm64": "2.5.5", + "@biomejs/cli-linux-arm64-musl": "2.5.5", + "@biomejs/cli-linux-x64": "2.5.5", + "@biomejs/cli-linux-x64-musl": "2.5.5", + "@biomejs/cli-win32-arm64": "2.5.5", + "@biomejs/cli-win32-x64": "2.5.5" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.3.tgz", - "integrity": "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.5.tgz", + "integrity": "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.3.tgz", - "integrity": "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.5.tgz", + "integrity": "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==", "cpu": [ "x64" ], @@ -158,9 +158,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.3.tgz", - "integrity": "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.5.tgz", + "integrity": "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==", "cpu": [ "arm64" ], @@ -178,9 +178,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.3.tgz", - "integrity": "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.5.tgz", + "integrity": "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==", "cpu": [ "arm64" ], @@ -198,9 +198,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.3.tgz", - "integrity": "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.5.tgz", + "integrity": "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==", "cpu": [ "x64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.3.tgz", - "integrity": "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.5.tgz", + "integrity": "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==", "cpu": [ "x64" ], @@ -238,9 +238,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.3.tgz", - "integrity": "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.5.tgz", + "integrity": "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==", "cpu": [ "arm64" ], @@ -255,9 +255,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.3.tgz", - "integrity": "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.5.tgz", + "integrity": "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==", "cpu": [ "x64" ], @@ -319,9 +319,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260708.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260708.1.tgz", - "integrity": "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", + "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", "cpu": [ "x64" ], @@ -336,9 +336,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260708.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260708.1.tgz", - "integrity": "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", + "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", "cpu": [ "arm64" ], @@ -353,9 +353,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260708.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260708.1.tgz", - "integrity": "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", + "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", "cpu": [ "x64" ], @@ -370,9 +370,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260708.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260708.1.tgz", - "integrity": "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", + "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", "cpu": [ "arm64" ], @@ -387,9 +387,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260708.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260708.1.tgz", - "integrity": "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", + "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", "cpu": [ "x64" ], @@ -404,9 +404,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260702.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", - "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "version": "5.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260722.1.tgz", + "integrity": "sha512-8+kivCgFGzwrAfNOWgSpzy/VDvmT/i5KWBgQhnygv3d1kajNn6mCYTbLKpouG0aY8mXjhv+IQm1a8r2K/H4pqQ==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -1002,12 +1002,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", + "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -2187,17 +2187,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2210,7 +2210,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2226,16 +2226,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -2251,14 +2251,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -2273,14 +2273,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2291,9 +2291,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -2308,15 +2308,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2333,9 +2333,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -2347,16 +2347,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2375,16 +2375,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2399,13 +2399,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2701,20 +2701,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -2724,10 +2724,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3481,9 +3494,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -3780,9 +3793,9 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4422,9 +4435,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -4639,16 +4652,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260708.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260708.1.tgz", - "integrity": "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==", + "version": "4.20260721.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", + "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", - "workerd": "1.20260708.1", + "workerd": "1.20260721.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -4659,579 +4672,6 @@ "node": ">=22.0.0" } }, - "node_modules/miniflare/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/miniflare/node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -5628,9 +5068,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -6301,9 +5741,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", - "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -6332,17 +5772,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -6360,16 +5817,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6714,9 +6171,9 @@ } }, "node_modules/workerd": { - "version": "1.20260708.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260708.1.tgz", - "integrity": "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", + "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -6727,11 +6184,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260708.1", - "@cloudflare/workerd-darwin-arm64": "1.20260708.1", - "@cloudflare/workerd-linux-64": "1.20260708.1", - "@cloudflare/workerd-linux-arm64": "1.20260708.1", - "@cloudflare/workerd-windows-64": "1.20260708.1" + "@cloudflare/workerd-darwin-64": "1.20260721.1", + "@cloudflare/workerd-darwin-arm64": "1.20260721.1", + "@cloudflare/workerd-linux-64": "1.20260721.1", + "@cloudflare/workerd-linux-arm64": "1.20260721.1", + "@cloudflare/workerd-windows-64": "1.20260721.1" } }, "node_modules/workers-mcp": { @@ -6761,9 +6218,9 @@ } }, "node_modules/wrangler": { - "version": "4.110.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz", - "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==", + "version": "4.113.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", + "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -6771,10 +6228,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260708.1", + "miniflare": "4.20260721.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260708.1" + "workerd": "1.20260721.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -6788,7 +6245,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260708.1" + "@cloudflare/workers-types": "^5.20260721.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index 7e44dd5..73696ed 100644 --- a/package.json +++ b/package.json @@ -76,11 +76,11 @@ "url": "https://github.com/hmmhmmhm/daiso-mcp/issues" }, "engines": { - "node": ">=20" + "node": ">=22" }, "devDependencies": { - "@biomejs/biome": "^2.5.2", - "@cloudflare/workers-types": "^4.20260702.1", + "@biomejs/biome": "^2.5.5", + "@cloudflare/workers-types": "^5.20260722.1", "@eslint/js": "^10.0.1", "@types/node": "^26.1.0", "@vitest/coverage-v8": "^4.1.7", @@ -89,26 +89,25 @@ "globals": "^17.7.0", "jiti": "^2.7.0", "playwright": "^1.61.1", - "prettier": "^3.9.4", + "prettier": "^3.9.6", "sharp": "^0.35.3", - "tsx": "^4.23.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.1", + "tsx": "^4.23.1", + "typescript": "6.0.3", + "typescript-eslint": "^8.65.0", "vitest": "^4.1.7", - "wrangler": "^4.107.0" + "wrangler": "^4.113.0" }, "overrides": { - "flatted": "3.4.2", - "undici": "7.28.0", - "brace-expansion": "5.0.6", - "ws": "8.21.0", - "tmp": "0.2.7", - "markdown-it": "14.2.0", - "esbuild": "0.28.1" + "@modelcontextprotocol/sdk": { + "@hono/node-server": "2.0.11" + }, + "miniflare": { + "sharp": "0.35.3" + } }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", - "hono": "^4.12.27", + "@modelcontextprotocol/sdk": "^1.29.0", + "hono": "^4.12.31", "proj4": "^2.20.9", "workers-mcp": "^0.0.13" } diff --git a/src/api/operationalAuth.ts b/src/api/operationalAuth.ts new file mode 100644 index 0000000..edbd7e4 --- /dev/null +++ b/src/api/operationalAuth.ts @@ -0,0 +1,50 @@ +export type OperationalAuthorization = 'authorized' | 'unauthorized' | 'not-configured'; + +const textEncoder = new TextEncoder(); + +export function readOperationalToken(headers: Headers): string { + const authorization = headers.get('Authorization') || ''; + if (authorization.toLowerCase().startsWith('bearer ')) { + return authorization.slice('bearer '.length).trim(); + } + + return (headers.get('x-health-check-key') || '').trim(); +} + +async function digestToken(token: string): Promise { + return crypto.subtle.digest('SHA-256', textEncoder.encode(token)); +} + +async function compareDigests(left: ArrayBuffer, right: ArrayBuffer): Promise { + const timingSafeEqual = crypto.subtle.timingSafeEqual; + if (typeof timingSafeEqual === 'function') { + return timingSafeEqual.call(crypto.subtle, left, right); + } + + // Node Web Crypto에는 Workers 확장 메서드가 없으므로 네이티브 HMAC 검증으로 비교한다. + const key = await crypto.subtle.importKey( + 'raw', + right, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + const signature = await crypto.subtle.sign('HMAC', key, right); + return crypto.subtle.verify('HMAC', key, signature, left); +} + +export async function authorizeOperationalRequest( + headers: Headers, + configuredSecret: string | undefined, +): Promise { + const secret = configuredSecret?.trim(); + if (!secret) { + return 'not-configured'; + } + + const [requestDigest, secretDigest] = await Promise.all([ + digestToken(readOperationalToken(headers)), + digestToken(secret), + ]); + return (await compareDigests(requestDigest, secretDigest)) ? 'authorized' : 'unauthorized'; +} diff --git a/src/api/routes/healthRoutes.ts b/src/api/routes/healthRoutes.ts index 75b9663..6870b33 100644 --- a/src/api/routes/healthRoutes.ts +++ b/src/api/routes/healthRoutes.ts @@ -5,21 +5,13 @@ import type { Hono } from 'hono'; import { errorResponse, type AppBindings } from '../response.js'; import { runHealthChecks, type HealthCheckMode } from '../healthChecks.js'; +import { authorizeOperationalRequest } from '../operationalAuth.js'; function parseBoolean(value: string | undefined): boolean { const normalized = (value || '').trim().toLowerCase(); return normalized === 'true' || normalized === '1' || normalized === 'y'; } -function readHealthCheckToken(headers: Headers): string { - const authorization = headers.get('Authorization') || ''; - if (authorization.toLowerCase().startsWith('bearer ')) { - return authorization.slice('bearer '.length).trim(); - } - - return (headers.get('x-health-check-key') || '').trim(); -} - function isBetterStackHealthProbe(userAgent: string | undefined): boolean { const normalized = (userAgent || '').toLowerCase(); return normalized.includes('better stack better uptime bot') || normalized === 'nginx-ssl early hints'; @@ -69,7 +61,7 @@ export function registerHealthRoutes(app: Hono<{ Bindings: AppBindings }>): void ); } - if (readHealthCheckToken(c.req.raw.headers) !== secret) { + if ((await authorizeOperationalRequest(c.req.raw.headers, secret)) !== 'authorized') { return errorResponse(c, 'UNAUTHORIZED_HEALTH_CHECK', '유효한 헬스 체크 시크릿 키가 필요합니다.', 401); } diff --git a/src/api/routes/rateLimitStatsRoutes.ts b/src/api/routes/rateLimitStatsRoutes.ts new file mode 100644 index 0000000..2bcd805 --- /dev/null +++ b/src/api/routes/rateLimitStatsRoutes.ts @@ -0,0 +1,366 @@ +/** + * 인증된 일일 호출 제한 집계 조회 라우트 + */ + +import type { Hono } from 'hono'; +import { + RATE_LIMIT_METRICS_LEDGER_NAME, + RATE_LIMIT_METRICS_RETENTION_DAYS, + RATE_LIMIT_SERVICES, + type RateLimitService, + type RateLimitStats, +} from '../../durableObjects/rateLimitMetricsStore.js'; +import { authorizeOperationalRequest } from '../operationalAuth.js'; +import { errorResponse, successResponse, type AppBindings, type ApiContext } from '../response.js'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const DEFAULT_RANGE_DAYS = 7; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const ALLOWED_QUERY_KEYS = new Set(['from', 'to', 'service']); + +type StatsInput = { + from: string; + to: string; + asOfDay: string; + service?: RateLimitService; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isExactDate(value: unknown): value is string { + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + return false; + } + const timestamp = Date.parse(`${value}T00:00:00.000Z`); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value; +} + +function isRateLimitService(value: unknown): value is RateLimitService { + return typeof value === 'string' && (RATE_LIMIT_SERVICES as readonly string[]).includes(value); +} + +function toKstDay(nowMs: number): string { + return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10); +} + +function addCalendarDays(day: string, days: number): string { + const date = new Date(`${day}T00:00:00.000Z`); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +function inclusiveDayCount(from: string, to: string): number { + const fromMs = Date.parse(`${from}T00:00:00.000Z`); + const toMs = Date.parse(`${to}T00:00:00.000Z`); + return (toMs - fromMs) / (24 * 60 * 60 * 1000) + 1; +} + +function hasStrictQueryShape(searchParams: URLSearchParams): boolean { + const counts = new Map(); + for (const [key] of searchParams) { + if (!ALLOWED_QUERY_KEYS.has(key)) { + return false; + } + const count = (counts.get(key) ?? 0) + 1; + if (count > 1) { + return false; + } + counts.set(key, count); + } + return counts.has('from') === counts.has('to'); +} + +function parseStatsInput(url: URL, nowMs: number): StatsInput | undefined { + if (!hasStrictQueryShape(url.searchParams)) { + return undefined; + } + + const serviceValue = url.searchParams.get('service'); + if (serviceValue !== null && !isRateLimitService(serviceValue)) { + return undefined; + } + + const currentDay = toKstDay(nowMs); + const earliestDay = addCalendarDays(currentDay, -(RATE_LIMIT_METRICS_RETENTION_DAYS - 1)); + const fromValue = url.searchParams.get('from'); + const toValue = url.searchParams.get('to'); + const from = fromValue ?? addCalendarDays(currentDay, -(DEFAULT_RANGE_DAYS - 1)); + const to = toValue ?? currentDay; + + if ( + !isExactDate(from) || + !isExactDate(to) || + from > to || + from < earliestDay || + to > currentDay || + inclusiveDayCount(from, to) > RATE_LIMIT_METRICS_RETENTION_DAYS + ) { + return undefined; + } + + return serviceValue === null + ? { from, to, asOfDay: currentDay } + : { from, to, asOfDay: currentDay, service: serviceValue }; +} + +function sanitizeAggregate(value: unknown): RateLimitStats['totals'] | undefined { + if (!isRecord(value)) { + return undefined; + } + const { blockedRequests, uniqueIdentities } = value; + if ( + typeof blockedRequests !== 'number' || + !Number.isSafeInteger(blockedRequests) || + blockedRequests < 0 || + typeof uniqueIdentities !== 'number' || + !Number.isSafeInteger(uniqueIdentities) || + uniqueIdentities < 0 || + uniqueIdentities > blockedRequests + ) { + return undefined; + } + return { blockedRequests, uniqueIdentities }; +} + +function sanitizeDailyRows(value: unknown, input: StatsInput): RateLimitStats['daily'] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const rows: RateLimitStats['daily'] = []; + let previousDay: string | undefined; + for (const valueRow of value) { + if (!isRecord(valueRow)) { + return undefined; + } + const aggregate = sanitizeAggregate(valueRow); + const { day } = valueRow; + if ( + !aggregate || + !isExactDate(day) || + day < input.from || + day > input.to || + (previousDay !== undefined && day <= previousDay) + ) { + return undefined; + } + rows.push({ day, ...aggregate }); + previousDay = day; + } + return rows; +} + +function sanitizeServiceRows( + value: unknown, + input: StatsInput, +): RateLimitStats['services'] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const rows: RateLimitStats['services'] = []; + let previousKey: string | undefined; + for (const valueRow of value) { + if (!isRecord(valueRow)) { + return undefined; + } + const aggregate = sanitizeAggregate(valueRow); + const { day, service } = valueRow; + const key = `${String(day)}\u0000${String(service)}`; + if ( + !aggregate || + !isExactDate(day) || + day < input.from || + day > input.to || + !isRateLimitService(service) || + (input.service !== undefined && service !== input.service) || + (previousKey !== undefined && key <= previousKey) + ) { + return undefined; + } + rows.push({ day, service, ...aggregate }); + previousKey = key; + } + return rows; +} + +function sumBlockedRequests(rows: Array<{ blockedRequests: number }>): number | undefined { + let total = 0; + for (const row of rows) { + total += row.blockedRequests; + if (!Number.isSafeInteger(total)) { + return undefined; + } + } + return total; +} + +function hasValidGroupedRelations(stats: RateLimitStats, input: StatsInput): boolean { + const { totals, daily, services } = stats; + if (totals.blockedRequests === 0) { + return totals.uniqueIdentities === 0 && daily.length === 0 && services.length === 0; + } + const dailyUniqueSum = daily.reduce((sum, row) => sum + row.uniqueIdentities, 0); + if ( + totals.uniqueIdentities > dailyUniqueSum || + daily.some( + (row) => + row.blockedRequests === 0 || + row.uniqueIdentities === 0 || + row.uniqueIdentities > totals.uniqueIdentities, + ) || + services.some( + (row) => + row.blockedRequests === 0 || + row.uniqueIdentities === 0 || + row.uniqueIdentities > totals.uniqueIdentities, + ) + ) { + return false; + } + + const dailyByDay = new Map(daily.map((row) => [row.day, row])); + const servicesByDay = new Map( + daily.map((row) => [row.day, []]), + ); + for (const row of services) { + const dailyRow = dailyByDay.get(row.day); + if (!dailyRow || row.uniqueIdentities > dailyRow.uniqueIdentities) { + return false; + } + servicesByDay.get(row.day)!.push(row); + } + + return daily.every((dailyRow) => { + const dayServices = servicesByDay.get(dailyRow.day)!; + if (input.service !== undefined) { + const serviceRow = dayServices[0]; + return ( + dayServices.length === 1 && + serviceRow?.service === input.service && + serviceRow.blockedRequests === dailyRow.blockedRequests && + serviceRow.uniqueIdentities === dailyRow.uniqueIdentities + ); + } + + const blockedRequests = dayServices.reduce((sum, row) => sum + row.blockedRequests, 0); + const uniqueIdentities = dayServices.map((row) => row.uniqueIdentities); + const uniqueSum = uniqueIdentities.reduce((sum, count) => sum + count, 0); + const uniqueMaximum = Math.max(0, ...uniqueIdentities); + return ( + blockedRequests === dailyRow.blockedRequests && + dailyRow.uniqueIdentities >= uniqueMaximum && + dailyRow.uniqueIdentities <= uniqueSum + ); + }); +} + +function sanitizeStats(value: unknown, input: StatsInput): RateLimitStats | undefined { + if (!isRecord(value)) { + return undefined; + } + const totals = sanitizeAggregate(value.totals); + const daily = sanitizeDailyRows(value.daily, input); + const services = sanitizeServiceRows(value.services, input); + if (!totals || !daily || !services) { + return undefined; + } + if ( + sumBlockedRequests(daily) !== totals.blockedRequests || + sumBlockedRequests(services) !== totals.blockedRequests + ) { + return undefined; + } + const stats = { totals, daily, services }; + return hasValidGroupedRelations(stats, input) ? stats : undefined; +} + +function unavailableResponse(c: ApiContext) { + return errorResponse( + c, + 'RATE_LIMIT_STATS_UNAVAILABLE', + '호출 제한 통계를 조회할 수 없습니다.', + 503, + ); +} + +export function registerRateLimitStatsRoutes(app: Hono<{ Bindings: AppBindings }>): void { + app.get('/api/rate-limit/stats', async (c) => { + const authorization = await authorizeOperationalRequest( + c.req.raw.headers, + c.env?.HEALTH_CHECK_SECRET, + ); + if (authorization === 'not-configured') { + return errorResponse( + c, + 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', + 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', + 503, + ); + } + if (authorization === 'unauthorized') { + return errorResponse( + c, + 'UNAUTHORIZED_RATE_LIMIT_STATS', + '유효한 운영 시크릿 키가 필요합니다.', + 401, + ); + } + + const input = parseStatsInput(new URL(c.req.url), Date.now()); + if (!input) { + return errorResponse( + c, + 'INVALID_RATE_LIMIT_STATS_QUERY', + '통계 조회 조건이 올바르지 않습니다.', + 400, + ); + } + + const namespace = c.env?.DAILY_RATE_LIMITER; + if (!namespace) { + console.error('호출 제한 통계 binding 누락'); + return unavailableResponse(c); + } + + let response: Response; + try { + const id = namespace.idFromName(RATE_LIMIT_METRICS_LEDGER_NAME); + const stub = namespace.get(id); + const backendUrl = new URL('https://daily-rate-limit/stats'); + backendUrl.searchParams.set('from', input.from); + backendUrl.searchParams.set('to', input.to); + backendUrl.searchParams.set('asOf', input.asOfDay); + if (input.service !== undefined) { + backendUrl.searchParams.set('service', input.service); + } + response = await stub.fetch(backendUrl.toString(), { method: 'GET' }); + } catch { + console.error('호출 제한 통계 Durable Object 호출 실패'); + return unavailableResponse(c); + } + + if (response.status !== 200) { + console.error('호출 제한 통계 Durable Object 응답 실패', response.status); + return unavailableResponse(c); + } + + let value: unknown; + try { + value = await response.json(); + } catch { + console.error('호출 제한 통계 응답 검증 실패'); + return unavailableResponse(c); + } + const stats = sanitizeStats(value, input); + if (!stats) { + console.error('호출 제한 통계 응답 검증 실패'); + return unavailableResponse(c); + } + if (toKstDay(Date.now()) !== input.asOfDay) { + console.error('호출 제한 통계 날짜 전환 감지'); + return unavailableResponse(c); + } + return successResponse(c, stats); + }); +} diff --git a/src/durableObjects/dailyRateLimiter.ts b/src/durableObjects/dailyRateLimiter.ts index a9ae4ab..df8cf15 100644 --- a/src/durableObjects/dailyRateLimiter.ts +++ b/src/durableObjects/dailyRateLimiter.ts @@ -2,10 +2,23 @@ * IP별 KST 일일 호출량을 저장하는 Durable Object */ +import { + RATE_LIMIT_SERVICES, + RateLimitMetricsStore, + StaleRateLimitEventDayError, + type BlockedRateLimitEvent, + type RateLimitService, +} from './rateLimitMetricsStore.js'; + export const DAILY_RATE_LIMIT = 3000; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; const COUNTER_KEY = 'counter'; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// DurableObjectId.toString()이 반환하는 namespace-scoped 불투명 ID 형식입니다. +const DURABLE_OBJECT_ID_PATTERN = /^[0-9a-f]{64}$/; +const BLOCKED_EVENT_FIELDS = ['eventId', 'occurredAt', 'day', 'service', 'identityId'] as const; interface StoredCounter { day: string; @@ -27,12 +40,123 @@ export function toKstDay(nowMs: number): string { export function nextKstMidnightEpochSeconds(nowMs: number): number { const shifted = new Date(nowMs + KST_OFFSET_MS); const nextMidnightUtcMs = - Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) - KST_OFFSET_MS; + Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) - + KST_OFFSET_MS; return Math.floor(nextMidnightUtcMs / 1000); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isExactDate(value: unknown): value is string { + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + return false; + } + const timestamp = Date.parse(`${value}T00:00:00.000Z`); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value; +} + +function isRateLimitService(value: unknown): value is RateLimitService { + return typeof value === 'string' && (RATE_LIMIT_SERVICES as readonly string[]).includes(value); +} + +function isBlockedRateLimitEvent(value: unknown): value is BlockedRateLimitEvent { + if ( + !isRecord(value) || + Object.keys(value).length !== BLOCKED_EVENT_FIELDS.length || + !BLOCKED_EVENT_FIELDS.every((field) => Object.hasOwn(value, field)) + ) { + return false; + } + + const occurredAt = value.occurredAt; + return ( + typeof value.eventId === 'string' && + UUID_PATTERN.test(value.eventId) && + typeof occurredAt === 'number' && + Number.isSafeInteger(occurredAt) && + occurredAt >= 0 && + Number.isFinite(new Date(occurredAt).getTime()) && + Number.isFinite(new Date(occurredAt + KST_OFFSET_MS).getTime()) && + isExactDate(value.day) && + value.day === toKstDay(occurredAt) && + isRateLimitService(value.service) && + typeof value.identityId === 'string' && + DURABLE_OBJECT_ID_PATTERN.test(value.identityId) + ); +} + +async function parseBlockedEvent(request: Request): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + return undefined; + } + if (!isBlockedRateLimitEvent(value)) { + return undefined; + } + + // 검증한 필드만 새 객체로 만들어 원장에 전달합니다. + return { + eventId: value.eventId, + occurredAt: value.occurredAt, + day: value.day, + service: value.service, + identityId: value.identityId, + }; +} + +function hasStrictStatsParameters(searchParams: URLSearchParams): boolean { + let fromCount = 0; + let toCount = 0; + let asOfCount = 0; + let serviceCount = 0; + for (const [key] of searchParams) { + if (key === 'from') { + fromCount += 1; + } else if (key === 'to') { + toCount += 1; + } else if (key === 'asOf') { + asOfCount += 1; + } else if (key === 'service') { + serviceCount += 1; + } else { + return false; + } + } + return fromCount === 1 && toCount === 1 && asOfCount === 1 && serviceCount <= 1; +} + +function parseStatsInput( + url: URL, +): { from: string; to: string; asOfDay: string; service?: RateLimitService } | undefined { + if (!hasStrictStatsParameters(url.searchParams)) { + return undefined; + } + const from = url.searchParams.get('from'); + const to = url.searchParams.get('to'); + const asOfDay = url.searchParams.get('asOf'); + const service = url.searchParams.get('service'); + if (!isExactDate(from) || !isExactDate(to) || !isExactDate(asOfDay) || from > to) { + return undefined; + } + if (service !== null && !isRateLimitService(service)) { + return undefined; + } + + return service === null ? { from, to, asOfDay } : { from, to, asOfDay, service }; +} + +function errorResponse(message: string, status: 400 | 404 | 409): Response { + return Response.json({ error: message }, { status }); +} + export class DailyRateLimiter { + private metricsStore: RateLimitMetricsStore | undefined; + constructor(private readonly state: DurableObjectState) {} async consume(nowMs = Date.now()): Promise { @@ -55,7 +179,52 @@ export class DailyRateLimiter { }; } - async fetch(): Promise { - return Response.json(await this.consume()); + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (request.method === 'POST' && url.pathname === '/consume') { + return Response.json(await this.consume()); + } + + if (request.method === 'POST' && url.pathname === '/blocked-events') { + const event = await parseBlockedEvent(request); + if (!event) { + return errorResponse('차단 이벤트 형식이 올바르지 않습니다.', 400); + } + const store = this.getMetricsStore(); + try { + await store.record(event); + } catch (error) { + if (error instanceof StaleRateLimitEventDayError) { + return errorResponse('차단 이벤트 날짜가 만료되었습니다.', 409); + } + throw error; + } + return new Response(null, { status: 204 }); + } + + if (request.method === 'GET' && url.pathname === '/stats') { + const input = parseStatsInput(url); + if (!input) { + return errorResponse('통계 조회 조건이 올바르지 않습니다.', 400); + } + if (input.asOfDay !== toKstDay(Date.now())) { + return errorResponse('통계 조회 기준 날짜가 만료되었습니다.', 409); + } + const store = this.getMetricsStore(); + return Response.json(await store.query(input)); + } + + return errorResponse('요청한 작업을 찾을 수 없습니다.', 404); + } + + async alarm(): Promise { + const store = this.getMetricsStore(); + await store.cleanup(); + await store.ensureAlarm(); + } + + private getMetricsStore(): RateLimitMetricsStore { + this.metricsStore ??= new RateLimitMetricsStore(this.state); + return this.metricsStore; } } diff --git a/src/durableObjects/rateLimitMetricsStore.ts b/src/durableObjects/rateLimitMetricsStore.ts new file mode 100644 index 0000000..f9027e5 --- /dev/null +++ b/src/durableObjects/rateLimitMetricsStore.ts @@ -0,0 +1,237 @@ +/** + * 차단된 일일 호출 제한 이벤트를 정확히 집계하는 SQLite 저장소 + */ + +export const RATE_LIMIT_METRICS_RETENTION_DAYS = 30; +export const RATE_LIMIT_METRICS_LEDGER_NAME = '__blocked-ledger-v1__'; +export const RATE_LIMIT_SERVICES = ['oliveyoung', 'cgv', 'cu', 'gs25', 'lottemart'] as const; + +export type RateLimitService = (typeof RATE_LIMIT_SERVICES)[number]; + +export interface BlockedRateLimitEvent { + eventId: string; + occurredAt: number; + day: string; + service: RateLimitService; + identityId: string; +} + +export class StaleRateLimitEventDayError extends Error { + constructor() { + super('차단 이벤트 날짜가 만료되었습니다.'); + this.name = 'StaleRateLimitEventDayError'; + } +} + +interface RateLimitAggregate { + blockedRequests: number; + uniqueIdentities: number; +} + +export type RateLimitStats = { + totals: RateLimitAggregate; + daily: Array; + services: Array; +}; + +type AggregateRow = { + blocked_requests: number; + unique_identities: number; +}; + +type DailyRow = AggregateRow & { + day: string; +}; + +type ServiceRow = DailyRow & { + service: string; +}; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const ALARM_DELAY_MS = 5 * 60 * 1000; +// 요청 경로의 SQLite 쓰기 증폭을 제한하고 나머지는 일일 alarm에 맡깁니다. +const OPPORTUNISTIC_CLEANUP_LIMIT = 100; + +function toKstDay(nowMs: number): string { + return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10); +} + +function retainedCutoffDay(asOfDay: string): string { + const asOf = new Date(`${asOfDay}T00:00:00.000Z`); + return new Date( + Date.UTC( + asOf.getUTCFullYear(), + asOf.getUTCMonth(), + asOf.getUTCDate() - (RATE_LIMIT_METRICS_RETENTION_DAYS - 1), + ), + ) + .toISOString() + .slice(0, 10); +} + +function retentionCutoffDay(nowMs: number): string { + return retainedCutoffDay(toKstDay(nowMs)); +} + +function nextAlarmTime(nowMs: number): number { + const shifted = new Date(nowMs + KST_OFFSET_MS); + return ( + Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) - + KST_OFFSET_MS + + ALARM_DELAY_MS + ); +} + +function isRateLimitService(service: string): service is RateLimitService { + return (RATE_LIMIT_SERVICES as readonly string[]).includes(service); +} + +function toAggregate(row: AggregateRow): RateLimitAggregate { + return { + blockedRequests: row.blocked_requests, + uniqueIdentities: row.unique_identities, + }; +} + +export class RateLimitMetricsStore { + private readonly sql: SqlStorage; + + constructor(private readonly state: DurableObjectState) { + this.sql = state.storage.sql; + this.initializeSchema(); + } + + async record(event: BlockedRateLimitEvent): Promise { + if (event.day !== toKstDay(event.occurredAt)) { + throw new TypeError('이벤트 날짜가 KST 발생 날짜와 일치하지 않습니다.'); + } + + await this.ensureAlarm(); + const commitNowMs = Date.now(); + if (event.day !== toKstDay(commitNowMs)) { + throw new StaleRateLimitEventDayError(); + } + this.cleanupExpiredBatch(commitNowMs); + this.sql.exec( + `INSERT OR IGNORE INTO blocked_events + (event_id, occurred_at, day, service, identity_id) + VALUES (?, ?, ?, ?, ?)`, + event.eventId, + event.occurredAt, + event.day, + event.service, + event.identityId, + ); + } + + async query(input: { + from: string; + to: string; + asOfDay: string; + service?: RateLimitService; + }): Promise { + if (input.service !== undefined && !isRateLimitService(input.service)) { + throw new TypeError('지원하지 않는 서비스입니다.'); + } + + const cutoffDay = retainedCutoffDay(input.asOfDay); + const serviceClause = input.service === undefined ? '' : ' AND service = ?'; + const bindings = + input.service === undefined + ? [input.from, input.to, cutoffDay] + : [input.from, input.to, cutoffDay, input.service]; + const whereClause = `day >= ? AND day <= ? AND day >= ?${serviceClause}`; + + const totals = this.sql + .exec( + `SELECT COUNT(*) AS blocked_requests, + COUNT(DISTINCT identity_id) AS unique_identities + FROM blocked_events + WHERE ${whereClause}`, + ...bindings, + ) + .one(); + const daily = this.sql + .exec( + `SELECT day, + COUNT(*) AS blocked_requests, + COUNT(DISTINCT identity_id) AS unique_identities + FROM blocked_events + WHERE ${whereClause} + GROUP BY day + ORDER BY day ASC`, + ...bindings, + ) + .toArray(); + const services = this.sql + .exec( + `SELECT day, + service, + COUNT(*) AS blocked_requests, + COUNT(DISTINCT identity_id) AS unique_identities + FROM blocked_events + WHERE ${whereClause} + GROUP BY day, service + ORDER BY day ASC, service ASC`, + ...bindings, + ) + .toArray(); + + const stats = { + totals: toAggregate(totals), + daily: daily.map((row) => ({ day: row.day, ...toAggregate(row) })), + services: services.map((row) => ({ + day: row.day, + service: row.service as RateLimitService, + ...toAggregate(row), + })), + }; + this.cleanupExpiredBatch(Date.now()); + return stats; + } + + async cleanup(nowMs = Date.now()): Promise { + this.sql.exec('DELETE FROM blocked_events WHERE day < ?', retentionCutoffDay(nowMs)); + } + + async ensureAlarm(nowMs = Date.now()): Promise { + if ((await this.state.storage.getAlarm()) !== null) { + return; + } + await this.state.storage.setAlarm(nextAlarmTime(nowMs)); + } + + private initializeSchema(): void { + this.sql.exec(`CREATE TABLE IF NOT EXISTS blocked_events ( + event_id TEXT PRIMARY KEY, + occurred_at INTEGER NOT NULL, + day TEXT NOT NULL, + service TEXT NOT NULL, + identity_id TEXT NOT NULL + )`); + this.sql.exec( + 'CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service ON blocked_events(day, service)', + ); + this.sql.exec( + 'CREATE INDEX IF NOT EXISTS idx_blocked_events_day_identity ON blocked_events(day, identity_id)', + ); + this.sql.exec( + 'CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service_identity ON blocked_events(day, service, identity_id)', + ); + } + + private cleanupExpiredBatch(nowMs: number): void { + this.sql.exec( + `DELETE FROM blocked_events + WHERE event_id IN ( + SELECT event_id + FROM blocked_events + WHERE day < ? + ORDER BY day ASC, event_id ASC + LIMIT ? + )`, + retentionCutoffDay(nowMs), + OPPORTUNISTIC_CLEANUP_LIMIT, + ); + } +} diff --git a/src/index.ts b/src/index.ts index a93a159..071cf55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ import { registerOpinetRoutes } from './api/routes/opinetRoutes.js'; import { registerCompareRoutes } from './api/routes/compareRoutes.js'; import { registerFeedbackRoutes } from './api/routes/feedbackRoutes.js'; import { registerHealthRoutes } from './api/routes/healthRoutes.js'; +import { registerRateLimitStatsRoutes } from './api/routes/rateLimitStatsRoutes.js'; import { buildConfigStatus } from './api/configStatus.js'; import { createDailyRateLimitMiddleware } from './middleware/dailyRateLimit.js'; export { DailyRateLimiter } from './durableObjects/dailyRateLimiter.js'; @@ -347,6 +348,7 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest); // 헬스 체크 엔드포인트 app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) })); registerHealthRoutes(app); +registerRateLimitStatsRoutes(app); // 프롬프트 페이지 (MCP 미지원 에이전트용) app.get('/prompt', (c) => { diff --git a/src/middleware/dailyRateLimit.ts b/src/middleware/dailyRateLimit.ts index 6ae4eaf..ca5f05b 100644 --- a/src/middleware/dailyRateLimit.ts +++ b/src/middleware/dailyRateLimit.ts @@ -4,26 +4,50 @@ import type { MiddlewareHandler } from 'hono'; import { errorResponse, type AppBindings } from '../api/response.js'; -import { DAILY_RATE_LIMIT, type DailyRateLimitResult } from '../durableObjects/dailyRateLimiter.js'; - -const PROTECTED_PREFIXES = [ - '/api/oliveyoung/', - '/api/cgv/', - '/api/cu/', - '/api/gs25/', - '/api/lottemart/', +import { + DAILY_RATE_LIMIT, + nextKstMidnightEpochSeconds, + toKstDay, + type DailyRateLimitResult, +} from '../durableObjects/dailyRateLimiter.js'; +import { + RATE_LIMIT_METRICS_LEDGER_NAME, + type BlockedRateLimitEvent, + type RateLimitService, +} from '../durableObjects/rateLimitMetricsStore.js'; + +const PROTECTED_SERVICES: ReadonlyArray<{ prefix: string; service: RateLimitService }> = [ + { prefix: '/api/oliveyoung/', service: 'oliveyoung' }, + { prefix: '/api/cgv/', service: 'cgv' }, + { prefix: '/api/cu/', service: 'cu' }, + { prefix: '/api/gs25/', service: 'gs25' }, + { prefix: '/api/lottemart/', service: 'lottemart' }, ] as const; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + // Cloudflare가 교차 존 Worker subrequest에 사용하는 공통 client IP const CROSS_ZONE_WORKER_CLIENT_IP = '2a06:98c0:3600::103'; -export function isDailyRateLimitedRequest(request: Request): boolean { +interface DailyRateLimitDecision { + result: DailyRateLimitResult; + service: RateLimitService; + identityId: DurableObjectId; + namespace: DurableObjectNamespace; + nowMs: number; +} + +function resolveRateLimitService(request: Request): RateLimitService | null { if (request.method !== 'GET') { - return false; + return null; } const pathname = new URL(request.url).pathname; - return PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix)); + return PROTECTED_SERVICES.find(({ prefix }) => pathname.startsWith(prefix))?.service ?? null; +} + +export function isDailyRateLimitedRequest(request: Request): boolean { + return resolveRateLimitService(request) !== null; } function isHealthCheckBypass(request: Request, env?: AppBindings): boolean { @@ -50,38 +74,133 @@ function resolveRateLimitIdentity(request: Request): string | null { return workerZone ? `worker-zone:${workerZone}` : ip; } -export async function consumeDailyRateLimit( +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isExactDate(value: unknown): value is string { + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + return false; + } + const timestamp = Date.parse(`${value}T00:00:00.000Z`); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value; +} + +function isSafeIntegerInRange(value: unknown, minimum: number, maximum: number): value is number { + return ( + typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum + ); +} + +function isDailyRateLimitResult(value: unknown): value is DailyRateLimitResult { + if (!isRecord(value) || typeof value.allowed !== 'boolean') { + return false; + } + const { allowed, count, remaining, resetAt, day } = value; + if ( + !isSafeIntegerInRange(count, 0, DAILY_RATE_LIMIT) || + !isSafeIntegerInRange(remaining, 0, DAILY_RATE_LIMIT) || + !isSafeIntegerInRange(resetAt, 1, Number.MAX_SAFE_INTEGER) || + !isExactDate(day) + ) { + return false; + } + + const countersMatch = count + remaining === DAILY_RATE_LIMIT; + return allowed ? countersMatch && count > 0 : count === DAILY_RATE_LIMIT && remaining === 0; +} + +async function consumeDailyRateLimitDecision( request: Request, env?: AppBindings, -): Promise { - if (!isDailyRateLimitedRequest(request) || isHealthCheckBypass(request, env)) { +): Promise { + const service = resolveRateLimitService(request); + if (!service || isHealthCheckBypass(request, env)) { return null; } const identity = resolveRateLimitIdentity(request); - if (!identity || !env?.DAILY_RATE_LIMITER) { + const namespace = env?.DAILY_RATE_LIMITER; + if (!identity || !namespace) { return null; } try { - const id = env.DAILY_RATE_LIMITER.idFromName(await hashRateLimitIdentity(identity)); - const stub = env.DAILY_RATE_LIMITER.get(id); + const identityId = namespace.idFromName(await hashRateLimitIdentity(identity)); + const stub = namespace.get(identityId); const response = await stub.fetch('https://daily-rate-limit/consume', { method: 'POST' }); if (!response.ok) { console.error('일일 호출 제한 확인 실패', `Durable Object HTTP ${response.status}`); return null; } - return (await response.json()) as DailyRateLimitResult; - } catch (error) { - console.error( - '일일 호출 제한 확인 실패', - error instanceof Error ? error.message : String(error), - ); + const result: unknown = await response.json(); + if (!isDailyRateLimitResult(result)) { + console.error('일일 호출 제한 응답 검증 실패'); + return null; + } + const nowMs = Date.now(); + if (result.day !== toKstDay(nowMs)) { + console.error('일일 호출 제한 날짜 전환 감지'); + return null; + } + if (result.resetAt !== nextKstMidnightEpochSeconds(nowMs)) { + console.error('일일 호출 제한 초기화 시각 검증 실패'); + return null; + } + + return { + result, + service, + identityId, + namespace, + nowMs, + }; + } catch { + console.error('일일 호출 제한 확인 실패'); return null; } } +export async function consumeDailyRateLimit( + request: Request, + env?: AppBindings, +): Promise { + return (await consumeDailyRateLimitDecision(request, env))?.result ?? null; +} + +async function recordBlockedRateLimitEvent(decision: DailyRateLimitDecision): Promise { + const event: BlockedRateLimitEvent = { + eventId: crypto.randomUUID(), + occurredAt: decision.nowMs, + day: decision.result.day, + service: decision.service, + identityId: decision.identityId.toString(), + }; + + try { + const ledgerId = decision.namespace.idFromName(RATE_LIMIT_METRICS_LEDGER_NAME); + const ledger = decision.namespace.get(ledgerId); + const response = await ledger.fetch('https://daily-rate-limit/blocked-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!(response instanceof Response)) { + console.error('일일 호출 제한 원장 기록 실패', '응답 형식 오류'); + return false; + } + if (response.status !== 204) { + console.error('일일 호출 제한 원장 기록 실패', `Durable Object HTTP ${response.status}`); + return false; + } + return true; + } catch { + console.error('일일 호출 제한 원장 기록 실패'); + return false; + } +} + export function setRateLimitHeaders(headers: Headers, result: DailyRateLimitResult): void { headers.set('X-RateLimit-Limit', String(DAILY_RATE_LIMIT)); headers.set('X-RateLimit-Remaining', String(result.remaining)); @@ -90,12 +209,16 @@ export function setRateLimitHeaders(headers: Headers, result: DailyRateLimitResu export function createDailyRateLimitMiddleware(): MiddlewareHandler<{ Bindings: AppBindings }> { return async (c, next) => { - const result = await consumeDailyRateLimit(c.req.raw, c.env); - if (!result) { + const decision = await consumeDailyRateLimitDecision(c.req.raw, c.env); + if (!decision) { return next(); } + const { result } = decision; if (!result.allowed) { + if (!(await recordBlockedRateLimitEvent(decision))) { + return next(); + } const response = errorResponse( c, 'DAILY_RATE_LIMIT_EXCEEDED', @@ -105,7 +228,7 @@ export function createDailyRateLimitMiddleware(): MiddlewareHandler<{ Bindings: setRateLimitHeaders(response.headers, result); response.headers.set( 'Retry-After', - String(Math.max(1, result.resetAt - Math.floor(Date.now() / 1000))), + String(Math.max(1, result.resetAt - Math.floor(decision.nowMs / 1000))), ); return response; } diff --git a/tests/api/operationalAuth.test.ts b/tests/api/operationalAuth.test.ts new file mode 100644 index 0000000..9e58b8e --- /dev/null +++ b/tests/api/operationalAuth.test.ts @@ -0,0 +1,100 @@ +import { timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { + authorizeOperationalRequest, + readOperationalToken, +} from '../../src/api/operationalAuth.js'; + +describe('readOperationalToken', () => { + it('Bearer 토큰을 대소문자와 공백을 무시해 우선 사용한다', () => { + const headers = new Headers({ + Authorization: 'bEaReR bearer-secret ', + 'x-health-check-key': 'health-check-secret', + }); + + expect(readOperationalToken(headers)).toBe('bearer-secret'); + }); + + it('Bearer 토큰이 없으면 x-health-check-key를 사용한다', () => { + const headers = new Headers({ 'x-health-check-key': ' health-check-secret ' }); + + expect(readOperationalToken(headers)).toBe('health-check-secret'); + }); +}); + +describe('authorizeOperationalRequest', () => { + it('설정된 시크릿이 없으면 not-configured를 반환한다', async () => { + const headers = new Headers({ Authorization: 'Bearer request-secret' }); + + await expect(authorizeOperationalRequest(headers, ' ')).resolves.toBe('not-configured'); + }); + + it('요청 토큰과 설정된 시크릿이 일치하면 authorized를 반환한다', async () => { + const headers = new Headers({ Authorization: 'Bearer request-secret' }); + + await expect(authorizeOperationalRequest(headers, ' request-secret ')).resolves.toBe( + 'authorized', + ); + }); + + it('요청 토큰이 올바르지 않으면 unauthorized를 반환한다', async () => { + const headers = new Headers({ Authorization: 'Bearer wrong-secret' }); + + await expect(authorizeOperationalRequest(headers, 'request-secret')).resolves.toBe( + 'unauthorized', + ); + }); + + it('요청 토큰이 없으면 unauthorized를 반환한다', async () => { + await expect(authorizeOperationalRequest(new Headers(), 'request-secret')).resolves.toBe( + 'unauthorized', + ); + }); + + it('길이가 다른 토큰도 예외 없이 unauthorized를 반환한다', async () => { + const headers = new Headers({ Authorization: 'Bearer short' }); + + await expect(authorizeOperationalRequest(headers, 'a-much-longer-secret')).resolves.toBe( + 'unauthorized', + ); + }); + + it('같은 길이와 접두사를 가진 다른 토큰도 예외 없이 unauthorized를 반환한다', async () => { + const headers = new Headers({ Authorization: 'Bearer shared-prefix-a' }); + + await expect(authorizeOperationalRequest(headers, 'shared-prefix-b')).resolves.toBe( + 'unauthorized', + ); + }); + + it('Workers timingSafeEqual에는 고정 길이 SHA-256 다이제스트를 전달한다', async () => { + const originalTimingSafeEqual = crypto.subtle.timingSafeEqual; + const timingSafeEqual = vi.fn((left: ArrayBuffer, right: ArrayBuffer) => { + expect(left.byteLength).toBe(32); + expect(right.byteLength).toBe(32); + return nodeTimingSafeEqual(new Uint8Array(left), new Uint8Array(right)); + }); + Object.defineProperty(crypto.subtle, 'timingSafeEqual', { + configurable: true, + value: timingSafeEqual, + }); + + try { + const headers = new Headers({ Authorization: 'Bearer request-secret' }); + + await expect(authorizeOperationalRequest(headers, 'request-secret')).resolves.toBe( + 'authorized', + ); + expect(timingSafeEqual).toHaveBeenCalledOnce(); + } finally { + if (typeof originalTimingSafeEqual === 'function') { + Object.defineProperty(crypto.subtle, 'timingSafeEqual', { + configurable: true, + value: originalTimingSafeEqual, + }); + } else { + Reflect.deleteProperty(crypto.subtle, 'timingSafeEqual'); + } + } + }); +}); diff --git a/tests/app/app-daily-rate-limit.test.ts b/tests/app/app-daily-rate-limit.test.ts index c929172..760b4a5 100644 --- a/tests/app/app-daily-rate-limit.test.ts +++ b/tests/app/app-daily-rate-limit.test.ts @@ -1,39 +1,39 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import app from '../../src/index.js'; -import type { AppBindings } from '../../src/api/response.js'; -import type { DailyRateLimitResult } from '../../src/durableObjects/dailyRateLimiter.js'; - -const originalCaches = (globalThis as { caches?: CacheStorage }).caches; - -function createResult(overrides: Partial = {}): DailyRateLimitResult { - return { - allowed: true, - count: 1, - remaining: 2999, - resetAt: Math.floor(Date.now() / 1000) + 3600, - day: '2026-07-17', - ...overrides, - }; -} - -function createRateLimitEnv(results: DailyRateLimitResult[], additional: Partial = {}) { - const queue = [...results]; - const stubFetch = vi.fn(async () => Response.json(queue.shift() ?? createResult())); - const get = vi.fn(() => ({ fetch: stubFetch })); - const idFromName = vi.fn((name: string) => ({ name }) as unknown as DurableObjectId); - const env: AppBindings = { - DAILY_RATE_LIMITER: { get, idFromName } as unknown as DurableObjectNamespace, - ...additional, - }; - - return { env, idFromName, get, stubFetch }; -} +import { + DailyRateLimiter, + nextKstMidnightEpochSeconds, +} from '../../src/durableObjects/dailyRateLimiter.js'; +import { + RATE_LIMIT_METRICS_LEDGER_NAME, + RateLimitMetricsStore, +} from '../../src/durableObjects/rateLimitMetricsStore.js'; +import { hashRateLimitIdentity } from '../../src/middleware/dailyRateLimit.js'; +import { + callPaths, + createRateLimitEnv, + createResult, + EVENT_ID, + KST_ROLLOVER_AFTER_MS, + KST_ROLLOVER_BEFORE_MS, + LEDGER_ID, + MALFORMED_QUOTA_RESULTS, + NOW_MS, + ORIGINAL_CACHES, + QUOTA_ID, + RATE_LIMIT_DAY, +} from '../helpers/dailyRateLimitFixture.js'; +import { createRateLimitSqlState } from '../helpers/rateLimitSqlStorage.js'; afterEach(() => { - (globalThis as { caches?: CacheStorage }).caches = originalCaches; + (globalThis as { caches?: CacheStorage }).caches = ORIGINAL_CACHES; vi.restoreAllMocks(); }); +beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(NOW_MS); +}); + describe('일일 호출 제한 통합', () => { it('허용 요청의 기존 응답에 rate limit 헤더를 추가한다', async () => { const fixture = createRateLimitEnv([createResult()]); @@ -49,24 +49,277 @@ describe('일일 호출 제한 통합', () => { expect(response.headers.get('X-RateLimit-Remaining')).toBe('2999'); expect(response.headers.get('X-RateLimit-Reset')).toBeTruthy(); expect(fixture.stubFetch).toHaveBeenCalledTimes(1); + expect(callPaths(fixture.calls)).toEqual(['/consume']); + }); + + it.each(MALFORMED_QUOTA_RESULTS)( + 'HTTP 200 %s quota JSON은 기록이나 제한 헤더 없이 fail-open한다', + async (_, malformedResult) => { + const fixture = createRateLimitEnv([malformedResult]); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + + expect(response.status).toBe(400); + expect((await response.json()).error.code).not.toBe('DAILY_RATE_LIMIT_EXCEEDED'); + expect(response.headers.get('X-RateLimit-Limit')).toBeNull(); + expect(response.headers.get('X-RateLimit-Remaining')).toBeNull(); + expect(response.headers.get('X-RateLimit-Reset')).toBeNull(); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(callPaths(fixture.calls)).toEqual(['/consume']); + expect(fixture.idFromName).not.toHaveBeenCalledWith(RATE_LIMIT_METRICS_LEDGER_NAME); + expect(consoleSpy).toHaveBeenCalledWith('일일 호출 제한 응답 검증 실패'); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('203.0.113.10'); + }, + ); + + it('3,000번째 허용 응답은 remaining 0인 정상 결과로 처리한다', async () => { + const fixture = createRateLimitEnv([ + createResult({ allowed: true, count: 3000, remaining: 0 }), + ]); + + const response = await app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + + expect(response.status).toBe(400); + expect(response.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(callPaths(fixture.calls)).toEqual(['/consume']); + }); + + it.each([ + ['허용', createResult()], + ['거부', createResult({ allowed: false, count: 3000, remaining: 0 })], + ] as const)('quota 응답 직후 KST 일자가 바뀐 %s 결정은 fail-open한다', async (_, result) => { + const nowSpy = vi.mocked(Date.now); + nowSpy.mockReturnValue(KST_ROLLOVER_BEFORE_MS); + const fixture = createRateLimitEnv([result], {}, 204, () => { + nowSpy.mockReturnValue(KST_ROLLOVER_AFTER_MS); + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + + expect(response.status).toBe(400); + expect((await response.json()).error.code).not.toBe('DAILY_RATE_LIMIT_EXCEEDED'); + expect(response.headers.get('X-RateLimit-Limit')).toBeNull(); + expect(response.headers.get('X-RateLimit-Remaining')).toBeNull(); + expect(response.headers.get('X-RateLimit-Reset')).toBeNull(); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(callPaths(fixture.calls)).toEqual(['/consume']); + expect(fixture.idFromName).not.toHaveBeenCalledWith(RATE_LIMIT_METRICS_LEDGER_NAME); + expect(consoleSpy).toHaveBeenCalledWith('일일 호출 제한 날짜 전환 감지'); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('203.0.113.10'); }); - it('한도 초과 요청은 handler 호출 전에 429와 초기화 헤더를 반환한다', async () => { + it.each([ + ['stale', Math.floor(NOW_MS / 1000) - 1], + ['future', nextKstMidnightEpochSeconds(NOW_MS) + 1], + ] as const)('정확한 다음 KST 자정이 아닌 %s resetAt은 fail-open한다', async (_, resetAt) => { + const denied = createResult({ allowed: false, count: 3000, remaining: 0, resetAt }); + const fixture = createRateLimitEnv([denied]); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + + expect(response.status).toBe(400); + expect((await response.json()).error.code).not.toBe('DAILY_RATE_LIMIT_EXCEEDED'); + expect(response.headers.get('X-RateLimit-Limit')).toBeNull(); + expect(response.headers.get('X-RateLimit-Remaining')).toBeNull(); + expect(response.headers.get('X-RateLimit-Reset')).toBeNull(); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(callPaths(fixture.calls)).toEqual(['/consume']); + expect(fixture.idFromName).not.toHaveBeenCalledWith(RATE_LIMIT_METRICS_LEDGER_NAME); + expect(consoleSpy).toHaveBeenCalledWith('일일 호출 제한 초기화 시각 검증 실패'); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('203.0.113.10'); + }); + + it.each([ + ['KST 자정 직전', KST_ROLLOVER_BEFORE_MS, RATE_LIMIT_DAY], + ['KST 자정 경계', KST_ROLLOVER_AFTER_MS, '2026-07-23'], + ] as const)('%s의 정확한 다음 자정 resetAt을 허용한다', async (_, nowMs, day) => { + vi.mocked(Date.now).mockReturnValue(nowMs); + const resetAt = nextKstMidnightEpochSeconds(nowMs); + const fixture = createRateLimitEnv([createResult({ day, resetAt })]); + + const response = await app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + + expect(response.status).toBe(400); + expect(response.headers.get('X-RateLimit-Reset')).toBe(String(resetAt)); + expect(callPaths(fixture.calls)).toEqual(['/consume']); + }); + + it.each([ + ['oliveyoung', '/api/oliveyoung/products'], + ['cgv', '/api/cgv/timetable'], + ['cu', '/api/cu/stores'], + ['gs25', '/api/gs25/products'], + ['lottemart', '/api/lottemart/products'], + ] as const)('%s 한도 초과 429마다 정확히 한 건을 원장에 먼저 기록한다', async (service, path) => { const denied = createResult({ allowed: false, count: 3000, remaining: 0 }); const fixture = createRateLimitEnv([denied]); const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const randomUuidSpy = vi.spyOn(crypto, 'randomUUID').mockReturnValue(EVENT_ID); + vi.mocked(Date.now).mockReturnValue(NOW_MS); const response = await app.request( - '/api/cgv/timetable?playDate=20260717&theaterCode=0056', + path, { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, fixture.env, ); + fixture.order.push('response:429'); expect(response.status).toBe(429); expect(response.headers.get('Retry-After')).toBeTruthy(); expect(response.headers.get('X-RateLimit-Remaining')).toBe('0'); expect((await response.json()).error.code).toBe('DAILY_RATE_LIMIT_EXCEEDED'); expect(fetchSpy).not.toHaveBeenCalled(); + expect(callPaths(fixture.calls)).toEqual(['/consume', '/blocked-events']); + expect(fixture.order).toEqual([ + 'consume:started', + 'consume:resolved', + 'record:started', + 'record:resolved', + 'response:429', + ]); + expect(fixture.idFromName).toHaveBeenLastCalledWith(RATE_LIMIT_METRICS_LEDGER_NAME); + expect(fixture.calls[1]?.durableObjectId).toBe(LEDGER_ID); + expect(randomUuidSpy).toHaveBeenCalledTimes(1); + + const ledgerRequest = fixture.calls[1]?.request; + expect(ledgerRequest?.method).toBe('POST'); + expect(ledgerRequest?.headers.get('Content-Type')).toBe('application/json'); + const body = await ledgerRequest?.json(); + expect(body).toEqual({ + eventId: EVENT_ID, + occurredAt: NOW_MS, + day: RATE_LIMIT_DAY, + service, + identityId: QUOTA_ID, + }); + }); + + it('교차 존 요청의 원장에는 원본·정규화 주체·단순 해시 대신 DO ID만 기록한다', async () => { + const denied = createResult({ allowed: false, count: 3000, remaining: 0 }); + const fixture = createRateLimitEnv([denied]); + const rawIp = '2a06:98c0:3600::103'; + const normalizedIdentity = 'worker-zone:example.com'; + const plainHash = await hashRateLimitIdentity(normalizedIdentity); + vi.spyOn(crypto, 'randomUUID').mockReturnValue(EVENT_ID); + vi.mocked(Date.now).mockReturnValue(KST_ROLLOVER_BEFORE_MS); + + const response = await app.request( + '/api/cgv/timetable', + { + headers: { + 'CF-Connecting-IP': rawIp, + 'CF-Worker': ' Example.COM ', + }, + }, + fixture.env, + ); + + expect(response.status).toBe(429); + const ledgerText = await fixture.calls[1]?.request.text(); + expect(ledgerText).toContain(`"identityId":"${QUOTA_ID}"`); + expect(ledgerText).not.toContain(rawIp); + expect(ledgerText).not.toContain(normalizedIdentity); + expect(ledgerText).not.toContain(plainHash); + expect(ledgerText).not.toContain('Example.COM'); + }); + + it.each([ + ['throw', 'throw'], + ['503', 503], + ['200', 200], + ['malformed', 'malformed'], + ] as const)( + '원장 %s 실패 시 애플리케이션 429 없이 downstream 응답으로 fail-open한다', + async (_, outcome) => { + const denied = createResult({ allowed: false, count: 3000, remaining: 0 }); + const fixture = createRateLimitEnv([denied], {}, outcome); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + + expect(response.status).toBe(400); + expect((await response.json()).error.code).not.toBe('DAILY_RATE_LIMIT_EXCEEDED'); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(callPaths(fixture.calls)).toEqual(['/consume', '/blocked-events']); + expect( + fixture.calls.filter(({ request }) => new URL(request.url).pathname === '/blocked-events'), + ).toHaveLength(1); + const logged = JSON.stringify(consoleSpy.mock.calls); + expect(logged).not.toContain('203.0.113.10'); + expect(logged).not.toContain('worker-zone:'); + }, + ); + + it('원장 대기 중 KST 날짜가 바뀌면 저장과 애플리케이션 429 없이 fail-open한다', async () => { + const nowSpy = vi.mocked(Date.now); + nowSpy.mockReturnValue(KST_ROLLOVER_BEFORE_MS); + const denied = createResult({ + allowed: false, + count: 3000, + remaining: 0, + resetAt: nextKstMidnightEpochSeconds(KST_ROLLOVER_BEFORE_MS), + }); + const ledgerState = createRateLimitSqlState(); + const ledger = new DailyRateLimiter(ledgerState.state); + let releaseAlarm: (() => void) | undefined; + const alarmGate = new Promise((resolve) => { + releaseAlarm = resolve; + }); + const ensureAlarm = vi + .spyOn(RateLimitMetricsStore.prototype, 'ensureAlarm') + .mockReturnValue(alarmGate); + const fixture = createRateLimitEnv([denied], {}, (request) => ledger.fetch(request)); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const responsePromise = app.request( + '/api/oliveyoung/products', + { headers: { 'CF-Connecting-IP': '203.0.113.10' } }, + fixture.env, + ); + await vi.waitFor(() => expect(ensureAlarm).toHaveBeenCalledOnce()); + + nowSpy.mockReturnValue(KST_ROLLOVER_AFTER_MS); + releaseAlarm?.(); + const response = await responsePromise; + + expect(response.status).toBe(400); + expect((await response.json()).error.code).not.toBe('DAILY_RATE_LIMIT_EXCEEDED'); + expect(response.headers.get('X-RateLimit-Limit')).toBeNull(); + expect(response.headers.get('X-RateLimit-Remaining')).toBeNull(); + expect(response.headers.get('X-RateLimit-Reset')).toBeNull(); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(callPaths(fixture.calls)).toEqual(['/consume', '/blocked-events']); + expect(ledgerState.readEvents()).toHaveLength(0); + expect(consoleSpy).toHaveBeenCalledWith( + '일일 호출 제한 원장 기록 실패', + 'Durable Object HTTP 409', + ); }); it('edge cache hit 응답도 호출량을 계산한다', async () => { @@ -90,6 +343,7 @@ describe('일일 호출 제한 통합', () => { expect(response.status).toBe(200); expect(response.headers.get('X-RateLimit-Remaining')).toBe('2998'); expect(fixture.stubFetch).toHaveBeenCalledTimes(1); + expect(callPaths(fixture.calls)).toEqual(['/consume']); expect(match).toHaveBeenCalledTimes(1); expect(put).not.toHaveBeenCalled(); }); diff --git a/tests/app/app-rate-limit-stats-logging.test.ts b/tests/app/app-rate-limit-stats-logging.test.ts new file mode 100644 index 0000000..c163e99 --- /dev/null +++ b/tests/app/app-rate-limit-stats-logging.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { + authenticatedHeaders, + createStatsFixture, + EMPTY_STATS, + NOW_MS, + SECRET, +} from './rateLimitStatsTestHelpers.js'; + +const STATS_PATH = '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22&service=cgv'; + +function expectSafeLog(spy: ReturnType): void { + const logged = JSON.stringify(spy.mock.calls); + expect(logged).not.toContain(SECRET); + expect(logged).not.toContain('203.0.113.10'); + expect(logged).not.toContain('opaque-id'); + expect(logged).not.toContain('sensitive-response-body'); + expect(logged).not.toContain('/api/rate-limit/stats'); + expect(logged).not.toContain('from='); + expect(logged).not.toContain('2026-07-20'); + expect(logged).not.toContain('Authorization'); + expect(logged).not.toContain('x-health-check-key'); +} + +async function requestStats(env: Parameters[2]): Promise { + return app.request( + STATS_PATH, + { + headers: { + ...authenticatedHeaders(), + 'CF-Connecting-IP': '203.0.113.10', + 'x-private-marker': 'opaque-id', + }, + }, + env, + ); +} + +beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(NOW_MS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('GET /api/rate-limit/stats 안전 로그', () => { + it('binding 누락은 고정 분류만 기록한다', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await requestStats({ HEALTH_CHECK_SECRET: SECRET }); + + expect(response.status).toBe(503); + expect(consoleSpy).toHaveBeenCalledWith('호출 제한 통계 binding 누락'); + expectSafeLog(consoleSpy); + }); + + it('backend non-200은 상태 번호와 고정 분류만 기록한다', async () => { + const fixture = createStatsFixture( + new Response('sensitive-response-body opaque-id', { status: 503 }), + ); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await requestStats(fixture.env); + + expect(response.status).toBe(503); + expect(consoleSpy).toHaveBeenCalledWith('호출 제한 통계 Durable Object 응답 실패', 503); + expectSafeLog(consoleSpy); + }); + + it.each([ + ['invalid JSON', new Response('{"identityId":"opaque-id"', { status: 200 })], + ['malformed aggregate', { ...EMPTY_STATS, identityId: 'opaque-id', daily: null }], + ] as const)('%s은 응답 검증 실패 분류만 기록한다', async (_, outcome) => { + const fixture = createStatsFixture(outcome); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await requestStats(fixture.env); + + expect(response.status).toBe(503); + expect(consoleSpy).toHaveBeenCalledWith('호출 제한 통계 응답 검증 실패'); + expectSafeLog(consoleSpy); + }); + + it('throw는 오류 객체 없이 호출 실패 분류만 기록한다', async () => { + const fixture = createStatsFixture('throw'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await requestStats(fixture.env); + + expect(response.status).toBe(503); + expect(consoleSpy).toHaveBeenCalledWith('호출 제한 통계 Durable Object 호출 실패'); + expectSafeLog(consoleSpy); + }); + + it('예상된 인증·query 4xx는 오류 로그를 남기지 않는다', async () => { + const fixture = createStatsFixture(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const unauthorized = await app.request( + '/api/rate-limit/stats', + { headers: { Authorization: 'Bearer rejected-secret' } }, + fixture.env, + ); + const invalid = await app.request( + '/api/rate-limit/stats?from=invalid', + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(unauthorized.status).toBe(401); + expect(invalid.status).toBe(400); + expect(consoleSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/app/app-rate-limit-stats.test.ts b/tests/app/app-rate-limit-stats.test.ts new file mode 100644 index 0000000..dc15544 --- /dev/null +++ b/tests/app/app-rate-limit-stats.test.ts @@ -0,0 +1,416 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { RATE_LIMIT_METRICS_LEDGER_NAME } from '../../src/durableObjects/rateLimitMetricsStore.js'; +import { + authenticatedHeaders, + CONTRADICTORY_STATS_CASES, + createStatsFixture, + EMPTY_STATS, + expectApiError, + FILTERED_STATS, + NOW_MS, + SECRET, + UNFILTERED_STATS, +} from './rateLimitStatsTestHelpers.js'; + +beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(NOW_MS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('GET /api/rate-limit/stats', () => { + it.each([undefined, '', ' '] as const)( + 'HEALTH_CHECK_SECRET이 %s이면 기존 운영 설정 오류 503을 반환한다', + async (secret) => { + const fixture = createStatsFixture(); + fixture.env.HEALTH_CHECK_SECRET = secret; + + const response = await app.request('/api/rate-limit/stats', undefined, fixture.env); + + await expectApiError( + response, + 503, + 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', + 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', + ); + expect(fixture.idFromName).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['누락', undefined], + ['잘못된 bearer', { Authorization: 'Bearer wrong-secret' }], + ['잘못된 key', { 'x-health-check-key': 'wrong-secret' }], + ] as const)('%s 인증은 query 검증과 DO 접근보다 먼저 401을 반환한다', async (_, headers) => { + const fixture = createStatsFixture(); + + const response = await app.request( + '/api/rate-limit/stats?from=not-a-date', + headers === undefined ? undefined : { headers }, + fixture.env, + ); + + await expectApiError( + response, + 401, + 'UNAUTHORIZED_RATE_LIMIT_STATS', + '유효한 운영 시크릿 키가 필요합니다.', + ); + expect(fixture.idFromName).not.toHaveBeenCalled(); + }); + + it.each(['bearer', 'key'] as const)('%s 인증을 공용 운영 인증으로 허용한다', async (header) => { + const fixture = createStatsFixture(); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-07-22&to=2026-07-22', + { headers: authenticatedHeaders(header) }, + fixture.env, + ); + + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual(EMPTY_STATS); + }); + + it.each([ + ['알 수 없는 key', 'from=2026-07-22&to=2026-07-22&extra=1'], + ['중복 from', 'from=2026-07-22&from=2026-07-21&to=2026-07-22'], + ['중복 to', 'from=2026-07-22&to=2026-07-22&to=2026-07-21'], + ['중복 service', 'from=2026-07-22&to=2026-07-22&service=cgv&service=cu'], + ['내부 전용 asOf', 'from=2026-07-22&to=2026-07-22&asOf=2026-07-22'], + ['from만 제공', 'from=2026-07-22'], + ['to만 제공', 'to=2026-07-22'], + ['빈 날짜', 'from=&to='], + ['형식이 다른 날짜', 'from=2026-7-01&to=2026-07-22'], + ['실재하지 않는 날짜', 'from=2026-02-30&to=2026-07-22'], + ['역순 날짜', 'from=2026-07-22&to=2026-07-21'], + ['대문자 서비스', 'from=2026-07-22&to=2026-07-22&service=CGV'], + ['빈 서비스', 'from=2026-07-22&to=2026-07-22&service='], + ] as const)('%s query shape를 400으로 거부하고 DO를 호출하지 않는다', async (_, query) => { + const fixture = createStatsFixture(); + + const response = await app.request( + `/api/rate-limit/stats?${query}`, + { headers: authenticatedHeaders() }, + fixture.env, + ); + + await expectApiError( + response, + 400, + 'INVALID_RATE_LIMIT_STATS_QUERY', + '통계 조회 조건이 올바르지 않습니다.', + ); + expect(fixture.idFromName).not.toHaveBeenCalled(); + }); + + it.each([ + ['보관 범위 이전', 'from=2026-06-22&to=2026-07-21'], + ['미래 종료일', 'from=2026-07-22&to=2026-07-23'], + ['31일 범위', 'from=2026-06-22&to=2026-07-22'], + ] as const)('%s 요청을 backend 호출 전에 400으로 거부한다', async (_, query) => { + const fixture = createStatsFixture(); + + const response = await app.request( + `/api/rate-limit/stats?${query}`, + { headers: authenticatedHeaders() }, + fixture.env, + ); + + await expectApiError( + response, + 400, + 'INVALID_RATE_LIMIT_STATS_QUERY', + '통계 조회 조건이 올바르지 않습니다.', + ); + expect(fixture.stubFetch).not.toHaveBeenCalled(); + }); + + it('보관 범위의 정확한 30일을 허용한다', async () => { + const fixture = createStatsFixture(); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-06-23&to=2026-07-22', + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(response.status).toBe(200); + expect(new URL(fixture.calls[0]!.url).search).toBe( + '?from=2026-06-23&to=2026-07-22&asOf=2026-07-22', + ); + }); + + it.each([ + ['KST 자정 직전', Date.parse('2026-07-21T14:59:59.999Z'), '2026-07-15', '2026-07-21'], + ['KST 자정 경계', Date.parse('2026-07-21T15:00:00.000Z'), '2026-07-16', '2026-07-22'], + ] as const)( + '%s에는 현재 KST 일자 포함 최근 7일을 기본값으로 쓴다', + async (_, nowMs, from, to) => { + vi.mocked(Date.now).mockReturnValue(nowMs); + const fixture = createStatsFixture(); + + const response = await app.request( + '/api/rate-limit/stats?service=cgv', + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(response.status).toBe(200); + expect(new URL(fixture.calls[0]!.url).search).toBe( + `?from=${from}&to=${to}&asOf=${to}&service=cgv`, + ); + expect(Date.now).toHaveBeenCalledTimes(2); + }, + ); + + it('binding이 없으면 인증과 검증 후 안정적인 503을 반환한다', async () => { + const response = await app.request( + '/api/rate-limit/stats', + { headers: authenticatedHeaders() }, + { HEALTH_CHECK_SECRET: SECRET }, + ); + + await expectApiError( + response, + 503, + 'RATE_LIMIT_STATS_UNAVAILABLE', + '호출 제한 통계를 조회할 수 없습니다.', + ); + }); + + it('예약 원장 객체에 인코딩한 필터를 전달하며 별도 quota 호출을 하지 않는다', async () => { + const fixture = createStatsFixture(FILTERED_STATS); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22&service=cgv', + { + headers: { + ...authenticatedHeaders(), + 'CF-Connecting-IP': '203.0.113.10', + }, + }, + fixture.env, + ); + + expect(response.status).toBe(200); + expect(fixture.idFromName).toHaveBeenCalledOnce(); + expect(fixture.idFromName).toHaveBeenCalledWith(RATE_LIMIT_METRICS_LEDGER_NAME); + expect(fixture.get).toHaveBeenCalledWith(fixture.ledgerId); + expect(fixture.calls).toHaveLength(1); + expect(fixture.calls[0]!.method).toBe('GET'); + expect(fixture.calls[0]!.url).toBe( + 'https://daily-rate-limit/stats?from=2026-07-20&to=2026-07-22&asOf=2026-07-22&service=cgv', + ); + }); + + it('DO 응답 대기 중 KST 날짜가 바뀌면 이전 날짜의 거짓 0 대신 503을 반환한다', async () => { + const beforeMidnight = Date.parse('2026-07-22T14:59:59.999Z'); + const afterMidnight = Date.parse('2026-07-22T15:00:00.000Z'); + const nowSpy = vi.mocked(Date.now); + nowSpy.mockReturnValue(beforeMidnight); + let releaseResponse: (() => void) | undefined; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + const fixture = createStatsFixture(async () => { + await responseGate; + return Response.json(EMPTY_STATS); + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const responsePromise = app.request( + '/api/rate-limit/stats?from=2026-07-22&to=2026-07-22', + { + headers: { + ...authenticatedHeaders(), + 'CF-Connecting-IP': '203.0.113.10', + }, + }, + fixture.env, + ); + await vi.waitFor(() => expect(fixture.stubFetch).toHaveBeenCalledOnce()); + + nowSpy.mockReturnValue(afterMidnight); + releaseResponse?.(); + const response = await responsePromise; + + await expectApiError( + response, + 503, + 'RATE_LIMIT_STATS_UNAVAILABLE', + '호출 제한 통계를 조회할 수 없습니다.', + ); + expect(new URL(fixture.calls[0]!.url).searchParams.get('asOf')).toBe('2026-07-22'); + expect(consoleSpy).toHaveBeenCalledWith('호출 제한 통계 날짜 전환 감지'); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('203.0.113.10'); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain(SECRET); + }); + + it.each([ + ['backend throw', 'throw'], + ['backend non-200', new Response('unavailable', { status: 500 })], + ['backend invalid JSON', new Response('{', { status: 200 })], + ['backend null JSON', null], + ] as const)('%s를 거짓 0이 아닌 503으로 반환한다', async (_, outcome) => { + const fixture = createStatsFixture(outcome); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22', + { headers: authenticatedHeaders() }, + fixture.env, + ); + + await expectApiError( + response, + 503, + 'RATE_LIMIT_STATS_UNAVAILABLE', + '호출 제한 통계를 조회할 수 없습니다.', + ); + }); + + it.each([ + ['필수 배열 누락', { totals: EMPTY_STATS.totals }], + ['합계가 객체 아님', { ...EMPTY_STATS, totals: null }], + ['음수 합계', { ...EMPTY_STATS, totals: { blockedRequests: -1, uniqueIdentities: 0 } }], + ['비정수 합계', { ...EMPTY_STATS, totals: { blockedRequests: 1.5, uniqueIdentities: 1 } }], + ['unsafe 합계', { ...EMPTY_STATS, totals: { blockedRequests: 2 ** 53, uniqueIdentities: 1 } }], + ['unique 초과', { ...EMPTY_STATS, totals: { blockedRequests: 1, uniqueIdentities: 2 } }], + ['daily 비배열', { ...EMPTY_STATS, daily: null }], + ['daily row 비객체', { ...EMPTY_STATS, daily: [null] }], + [ + 'daily 범위 밖', + { ...EMPTY_STATS, daily: [{ day: '2026-07-19', blockedRequests: 0, uniqueIdentities: 0 }] }, + ], + [ + 'daily 비결정 순서', + { + ...EMPTY_STATS, + daily: [ + { day: '2026-07-22', blockedRequests: 0, uniqueIdentities: 0 }, + { day: '2026-07-20', blockedRequests: 0, uniqueIdentities: 0 }, + ], + }, + ], + ['services 비배열', { ...EMPTY_STATS, services: null }], + ['service row 비객체', { ...EMPTY_STATS, services: [null] }], + [ + '알 수 없는 서비스', + { + ...EMPTY_STATS, + services: [ + { day: '2026-07-20', service: 'unknown', blockedRequests: 0, uniqueIdentities: 0 }, + ], + }, + ], + [ + '필터와 다른 서비스', + { + ...EMPTY_STATS, + services: [{ day: '2026-07-20', service: 'cu', blockedRequests: 0, uniqueIdentities: 0 }], + }, + ], + [ + '합산 overflow', + { + totals: { blockedRequests: Number.MAX_SAFE_INTEGER, uniqueIdentities: 0 }, + daily: [ + { + day: '2026-07-20', + blockedRequests: Number.MAX_SAFE_INTEGER, + uniqueIdentities: 0, + }, + { day: '2026-07-21', blockedRequests: 1, uniqueIdentities: 0 }, + ], + services: [], + }, + ], + ] as const)('malformed aggregate(%s)는 503을 반환한다', async (_, stats) => { + const fixture = createStatsFixture(stats); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22&service=cgv', + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(response.status).toBe(503); + }); + + it.each(CONTRADICTORY_STATS_CASES)( + '모순 집계(%s)는 전체 합계가 맞아도 503을 반환한다', + async (_, stats, requestKind) => { + const fixture = createStatsFixture(stats); + const service = requestKind === 'filtered' ? '&service=cgv' : ''; + + const response = await app.request( + `/api/rate-limit/stats?from=2026-07-20&to=2026-07-22${service}`, + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(response.status).toBe(503); + }, + ); + + it.each([ + ['empty', EMPTY_STATS, ''], + ['filtered', FILTERED_STATS, '&service=cgv'], + ['unfiltered', UNFILTERED_STATS, ''], + ] as const)('유효한 %s 집계를 유지한다', async (_, stats, service) => { + const fixture = createStatsFixture(stats); + + const response = await app.request( + `/api/rate-limit/stats?from=2026-07-20&to=2026-07-22${service}`, + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual(stats); + }); + + it.each([201, 204, 206, 409] as const)( + '내부 /stats HTTP %s는 503으로 거부한다', + async (status) => { + const body = status === 204 ? null : JSON.stringify(FILTERED_STATS); + const fixture = createStatsFixture( + new Response(body, { status, headers: { 'Content-Type': 'application/json' } }), + ); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22&service=cgv', + { headers: authenticatedHeaders() }, + fixture.env, + ); + + expect(response.status).toBe(503); + }, + ); + + it('검증된 집계 필드만 직렬화하고 원장 식별 정보는 전달하지 않는다', async () => { + const privateStats = { + ...FILTERED_STATS, + identityId: 'opaque-id', + eventId: 'event-1', + rawIp: '203.0.113.10', + totals: { ...FILTERED_STATS.totals, identityIds: ['opaque-id'] }, + daily: FILTERED_STATS.daily.map((row) => ({ ...row, identityId: 'opaque-id' })), + services: FILTERED_STATS.services.map((row) => ({ ...row, eventIds: ['event-1'] })), + }; + const fixture = createStatsFixture(privateStats); + + const response = await app.request( + '/api/rate-limit/stats?from=2026-07-20&to=2026-07-22&service=cgv', + { headers: authenticatedHeaders() }, + fixture.env, + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ success: true, data: FILTERED_STATS }); + expect(JSON.stringify(body)).not.toMatch(/identityId|eventId|rawIp|203\.0\.113\.10/); + }); +}); diff --git a/tests/app/rateLimitStatsTestHelpers.ts b/tests/app/rateLimitStatsTestHelpers.ts new file mode 100644 index 0000000..042e76d --- /dev/null +++ b/tests/app/rateLimitStatsTestHelpers.ts @@ -0,0 +1,242 @@ +import { expect, vi } from 'vitest'; +import type { AppBindings } from '../../src/api/response.js'; +import type { RateLimitStats } from '../../src/durableObjects/rateLimitMetricsStore.js'; + +export const SECRET = 'test-secret'; +export const NOW_MS = Date.parse('2026-07-22T03:00:00.000Z'); +export const EMPTY_STATS: RateLimitStats = { + totals: { blockedRequests: 0, uniqueIdentities: 0 }, + daily: [], + services: [], +}; +export const FILTERED_STATS: RateLimitStats = { + totals: { blockedRequests: 3, uniqueIdentities: 2 }, + daily: [ + { day: '2026-07-20', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', blockedRequests: 2, uniqueIdentities: 2 }, + ], + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 2, uniqueIdentities: 2 }, + ], +}; +export const UNFILTERED_STATS: RateLimitStats = { + totals: { blockedRequests: 5, uniqueIdentities: 3 }, + daily: [ + { day: '2026-07-20', blockedRequests: 2, uniqueIdentities: 2 }, + { day: '2026-07-22', blockedRequests: 3, uniqueIdentities: 3 }, + ], + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-20', service: 'cu', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 2, uniqueIdentities: 2 }, + { day: '2026-07-22', service: 'cu', blockedRequests: 1, uniqueIdentities: 1 }, + ], +}; + +export const CONTRADICTORY_STATS_CASES: ReadonlyArray< + readonly [string, unknown, 'filtered' | 'unfiltered'] +> = [ + [ + 'unfiltered 일자별 blocked 배분 불일치', + { + ...UNFILTERED_STATS, + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-20', service: 'cu', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cu', blockedRequests: 1, uniqueIdentities: 1 }, + ], + }, + 'unfiltered', + ], + [ + 'filtered 일자별 blocked 불일치', + { + ...FILTERED_STATS, + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }, + ], + }, + 'filtered', + ], + [ + 'filtered 일자별 unique 불일치', + { + totals: { blockedRequests: 5, uniqueIdentities: 2 }, + daily: [ + { day: '2026-07-20', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-22', blockedRequests: 3, uniqueIdentities: 2 }, + ], + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 2, uniqueIdentities: 2 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 3, uniqueIdentities: 1 }, + ], + }, + 'filtered', + ], + [ + 'daily unique가 global unique 초과', + { ...FILTERED_STATS, totals: { blockedRequests: 3, uniqueIdentities: 1 } }, + 'filtered', + ], + [ + 'global unique가 일별 distinct 합 초과', + { + totals: { blockedRequests: 4, uniqueIdentities: 3 }, + daily: [ + { day: '2026-07-20', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-22', blockedRequests: 2, uniqueIdentities: 1 }, + ], + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 2, uniqueIdentities: 1 }, + ], + }, + 'filtered', + ], + [ + 'service unique가 daily와 global unique 초과', + { + ...FILTERED_STATS, + totals: { blockedRequests: 3, uniqueIdentities: 1 }, + daily: FILTERED_STATS.daily.map((row) => ({ ...row, uniqueIdentities: 1 })), + }, + 'filtered', + ], + [ + 'daily zero grouped row', + { + ...EMPTY_STATS, + daily: [{ day: '2026-07-20', blockedRequests: 0, uniqueIdentities: 0 }], + }, + 'unfiltered', + ], + [ + 'service zero grouped row', + { + ...EMPTY_STATS, + services: [{ day: '2026-07-20', service: 'cgv', blockedRequests: 0, uniqueIdentities: 0 }], + }, + 'unfiltered', + ], + [ + 'positive daily row의 zero unique', + { + totals: { blockedRequests: 1, uniqueIdentities: 0 }, + daily: [{ day: '2026-07-20', blockedRequests: 1, uniqueIdentities: 0 }], + services: [{ day: '2026-07-20', service: 'cgv', blockedRequests: 1, uniqueIdentities: 0 }], + }, + 'unfiltered', + ], + [ + 'service가 daily에 없는 날짜에 배정됨', + { + totals: { blockedRequests: 1, uniqueIdentities: 1 }, + daily: [{ day: '2026-07-20', blockedRequests: 1, uniqueIdentities: 1 }], + services: [{ day: '2026-07-21', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }], + }, + 'unfiltered', + ], + [ + 'daily unique가 service unique 최댓값보다 작음', + { + totals: { blockedRequests: 4, uniqueIdentities: 3 }, + daily: [{ day: '2026-07-20', blockedRequests: 4, uniqueIdentities: 2 }], + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 3, uniqueIdentities: 3 }, + { day: '2026-07-20', service: 'cu', blockedRequests: 1, uniqueIdentities: 1 }, + ], + }, + 'unfiltered', + ], + [ + 'daily unique가 service unique 합보다 큼', + { + totals: { blockedRequests: 4, uniqueIdentities: 3 }, + daily: [{ day: '2026-07-20', blockedRequests: 4, uniqueIdentities: 3 }], + services: [ + { day: '2026-07-20', service: 'cgv', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-20', service: 'cu', blockedRequests: 2, uniqueIdentities: 1 }, + ], + }, + 'unfiltered', + ], + [ + 'duplicate daily row', + { ...EMPTY_STATS, daily: [FILTERED_STATS.daily[0], FILTERED_STATS.daily[0]] }, + 'unfiltered', + ], + [ + 'duplicate service row', + { ...EMPTY_STATS, services: [FILTERED_STATS.services[0], FILTERED_STATS.services[0]] }, + 'unfiltered', + ], + [ + 'totals unique가 blocked 초과', + { ...EMPTY_STATS, totals: { blockedRequests: 1, uniqueIdentities: 2 } }, + 'unfiltered', + ], + [ + 'daily unique가 blocked 초과', + { + ...EMPTY_STATS, + daily: [{ day: '2026-07-20', blockedRequests: 1, uniqueIdentities: 2 }], + }, + 'unfiltered', + ], + [ + 'service unique가 blocked 초과', + { + ...EMPTY_STATS, + services: [{ day: '2026-07-20', service: 'cgv', blockedRequests: 1, uniqueIdentities: 2 }], + }, + 'unfiltered', + ], +]; + +export type BackendOutcome = unknown | Response | 'throw'; + +export function createStatsFixture(outcome: BackendOutcome = EMPTY_STATS) { + const calls: Request[] = []; + const ledgerId = { toString: () => 'reserved-ledger-id' } as unknown as DurableObjectId; + const stubFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + calls.push(request); + if (outcome === 'throw') { + throw new Error('backend failed with test-secret and 203.0.113.10'); + } + if (typeof outcome === 'function') { + return outcome(request) as Response | Promise; + } + return outcome instanceof Response ? outcome : Response.json(outcome); + }); + const get = vi.fn(() => ({ fetch: stubFetch }) as unknown as DurableObjectStub); + const idFromName = vi.fn(() => ledgerId); + const env: AppBindings = { + HEALTH_CHECK_SECRET: SECRET, + DAILY_RATE_LIMITER: { get, idFromName } as unknown as DurableObjectNamespace, + }; + + return { calls, env, get, idFromName, ledgerId, stubFetch }; +} + +export function authenticatedHeaders(header: 'bearer' | 'key' = 'bearer'): HeadersInit { + return header === 'bearer' + ? { Authorization: `Bearer ${SECRET}` } + : { 'x-health-check-key': SECRET }; +} + +export async function expectApiError( + response: Response, + status: number, + code: string, + message: string, +) { + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ + success: false, + error: { code, message }, + }); +} diff --git a/tests/durableObjects/dailyRateLimiter.test.ts b/tests/durableObjects/dailyRateLimiter.test.ts index 27c522d..a09a641 100644 --- a/tests/durableObjects/dailyRateLimiter.test.ts +++ b/tests/durableObjects/dailyRateLimiter.test.ts @@ -1,17 +1,33 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DAILY_RATE_LIMIT, DailyRateLimiter, nextKstMidnightEpochSeconds, toKstDay, } from '../../src/durableObjects/dailyRateLimiter.js'; +import { + RateLimitMetricsStore, + type BlockedRateLimitEvent, +} from '../../src/durableObjects/rateLimitMetricsStore.js'; +import { INVALID_INTERNAL_STATS_PATHS } from '../helpers/dailyRateLimiterStatsCases.js'; +import { createRateLimitSqlState } from '../helpers/rateLimitSqlStorage.js'; interface StoredCounter { day: string; count: number; } +const VALID_IDENTITY_ID = '0123456789abcdef'.repeat(4); +const VALID_EVENT: BlockedRateLimitEvent = { + eventId: '123e4567-e89b-42d3-a456-426614174000', + occurredAt: Date.parse('2026-07-22T03:00:00+09:00'), + day: '2026-07-22', + service: 'cgv', + identityId: VALID_IDENTITY_ID, +}; + function createState(initial?: StoredCounter) { + const metrics = createRateLimitSqlState(); const values = new Map(); if (initial) { values.set('counter', initial); @@ -21,17 +37,28 @@ function createState(initial?: StoredCounter) { const put = vi.fn(async (key: string, value: unknown) => { values.set(key, value); }); + Object.assign(metrics.state.storage, { get, put }); return { - state: { - storage: { get, put }, - } as unknown as DurableObjectState, + ...metrics, get, put, values, }; } +function request(path: string, init?: RequestInit): Request { + return new Request(`https://호스트는-라우팅에-쓰지-않음.example${path}`, init); +} + +function eventBody(overrides: Record = {}): string { + return JSON.stringify({ ...VALID_EVENT, ...overrides }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('DailyRateLimiter', () => { it('3,000번째 요청은 허용하고 다음 요청부터 저장 횟수 증가 없이 거부한다', async () => { const fixture = createState({ day: '2026-07-17', count: DAILY_RATE_LIMIT - 1 }); @@ -80,17 +107,309 @@ describe('DailyRateLimiter', () => { expect(fixture.get).toHaveBeenCalledWith('counter'); }); - it('fetch는 현재 시각 기준 소비 결과를 JSON으로 반환한다', async () => { + it('POST /consume은 현재 시각 기준 소비 결과를 JSON으로 반환하고 원장 저장소를 만들지 않는다', async () => { const fixture = createState(); const limiter = new DailyRateLimiter(fixture.state); const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-17T00:00:00Z')); - const response = await limiter.fetch(); + const response = await limiter.fetch(request('/consume', { method: 'POST' })); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ allowed: true, count: 1, day: '2026-07-17' }); + expect(fixture.sql.executed).toHaveLength(0); nowSpy.mockRestore(); }); + + it('POST /blocked-events는 완전한 이벤트 저장이 끝난 뒤 빈 204를 반환한다', async () => { + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + const response = await limiter.fetch( + request('/blocked-events', { method: 'POST', body: eventBody() }), + ); + + expect(response.status).toBe(204); + expect(await response.text()).toBe(''); + expect(fixture.readEvents()).toEqual([VALID_EVENT]); + }); + + it('POST /blocked-events는 record 완료 전에는 응답을 확정하지 않는다', async () => { + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + let finishRecord: (() => void) | undefined; + const record = vi.spyOn(RateLimitMetricsStore.prototype, 'record').mockImplementation( + () => + new Promise((resolve) => { + finishRecord = resolve; + }), + ); + let settled = false; + + const responsePromise = limiter + .fetch(request('/blocked-events', { method: 'POST', body: eventBody() })) + .finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()); + + expect(settled).toBe(false); + finishRecord?.(); + await expect(responsePromise).resolves.toMatchObject({ status: 204 }); + }); + + it('POST /blocked-events는 alarm 대기 중 날짜가 지난 이벤트를 본문 노출 없이 409로 거부한다', async () => { + const beforeMidnight = Date.parse('2026-07-22T14:59:59.999Z'); + const afterMidnight = Date.parse('2026-07-22T15:00:00.000Z'); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(beforeMidnight); + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + let releaseAlarm: (() => void) | undefined; + const alarmGate = new Promise((resolve) => { + releaseAlarm = resolve; + }); + const ensureAlarm = vi + .spyOn(RateLimitMetricsStore.prototype, 'ensureAlarm') + .mockReturnValue(alarmGate); + const responsePromise = limiter.fetch( + request('/blocked-events', { + method: 'POST', + body: eventBody({ occurredAt: beforeMidnight }), + }), + ); + await vi.waitFor(() => expect(ensureAlarm).toHaveBeenCalledOnce()); + + nowSpy.mockReturnValue(afterMidnight); + releaseAlarm?.(); + const response = await responsePromise; + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body).toEqual({ error: '차단 이벤트 날짜가 만료되었습니다.' }); + expect(JSON.stringify(body)).not.toContain('StaleRateLimitEventDayError'); + expect(JSON.stringify(body)).not.toContain(VALID_EVENT.eventId); + expect(JSON.stringify(body)).not.toContain(VALID_IDENTITY_ID); + expect(fixture.readEvents()).toHaveLength(0); + }); + + it.each([ + ['깨진 JSON', '{"eventId":'], + ['null', 'null'], + ['배열', '[]'], + ['문자열', '"event"'], + ['필드 누락', JSON.stringify({ eventId: VALID_EVENT.eventId })], + ['알 수 없는 필드', eventBody({ rawIp: '203.0.113.10' })], + ['UUID가 아닌 eventId', eventBody({ eventId: 'event-1' })], + ['무한대 occurredAt', eventBody().replace(String(VALID_EVENT.occurredAt), '1e400')], + ['음수 occurredAt', eventBody({ occurredAt: -1 })], + ['소수 occurredAt', eventBody({ occurredAt: VALID_EVENT.occurredAt + 0.5 })], + ['KST 계산 범위를 벗어난 occurredAt', eventBody({ occurredAt: 8_640_000_000_000_000 })], + ['정확하지 않은 날짜 형식', eventBody({ day: '2026-7-22' })], + ['존재하지 않는 날짜', eventBody({ day: '2026-02-30' })], + ['KST 발생일과 다른 날짜', eventBody({ day: '2026-07-21' })], + ['지원하지 않는 서비스', eventBody({ service: 'megabox' })], + ['빈 identityId', eventBody({ identityId: ' ' })], + ['원본 IP identityId', eventBody({ identityId: '203.0.113.10' })], + ['대문자 64자 identityId', eventBody({ identityId: 'A'.repeat(64) })], + ])('%s 이벤트는 400을 반환하고 기록하거나 본문을 로그하지 않는다', async (_, body) => { + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + const record = vi.spyOn(RateLimitMetricsStore.prototype, 'record'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const response = await limiter.fetch(request('/blocked-events', { method: 'POST', body })); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: expect.any(String) }); + expect(record).not.toHaveBeenCalled(); + expect(fixture.sql.executed).toHaveLength(0); + expect(consoleError).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('GET /stats는 날짜와 서비스 필터를 파싱해 집계 JSON을 반환한다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T03:00:00+09:00')); + const fixture = createState(); + const setupStore = new RateLimitMetricsStore(fixture.state); + await setupStore.record(VALID_EVENT); + const query = vi.spyOn(RateLimitMetricsStore.prototype, 'query'); + const limiter = new DailyRateLimiter(fixture.state); + + const response = await limiter.fetch( + request('/stats?service=cgv&to=2026-07-22&asOf=2026-07-22&from=2026-07-22'), + ); + + expect(response.status).toBe(200); + expect(query).toHaveBeenCalledWith({ + from: '2026-07-22', + to: '2026-07-22', + asOfDay: '2026-07-22', + service: 'cgv', + }); + expect(await response.json()).toEqual({ + totals: { blockedRequests: 1, uniqueIdentities: 1 }, + daily: [{ day: '2026-07-22', blockedRequests: 1, uniqueIdentities: 1 }], + services: [ + { + day: '2026-07-22', + service: 'cgv', + blockedRequests: 1, + uniqueIdentities: 1, + }, + ], + }); + }); + + it('GET /stats는 생략된 서비스 필터를 저장소에 전달하지 않는다', async () => { + const fixture = createState(); + const query = vi.spyOn(RateLimitMetricsStore.prototype, 'query'); + const limiter = new DailyRateLimiter(fixture.state); + + const response = await limiter.fetch( + request('/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22'), + ); + + expect(response.status).toBe(200); + expect(query).toHaveBeenCalledWith({ + from: '2026-07-21', + to: '2026-07-22', + asOfDay: '2026-07-22', + }); + }); + + it('같은 인스턴스는 계측 저장소를 재사용하고 다른 인스턴스와 상태를 공유하지 않는다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T03:00:00+09:00')); + const first = createState(); + const firstLimiter = new DailyRateLimiter(first.state); + + await firstLimiter.fetch(request('/blocked-events', { method: 'POST', body: eventBody() })); + await firstLimiter.fetch(request('/stats?from=2026-07-22&to=2026-07-22&asOf=2026-07-22')); + await firstLimiter.alarm(); + + const second = createState(); + const secondLimiter = new DailyRateLimiter(second.state); + await secondLimiter.fetch(request('/stats?from=2026-07-22&to=2026-07-22&asOf=2026-07-22')); + + expect(first.sql.executed.filter(({ query }) => query.startsWith('CREATE'))).toHaveLength(4); + expect(second.sql.executed.filter(({ query }) => query.startsWith('CREATE'))).toHaveLength(4); + expect(first.readEvents()).toEqual([VALID_EVENT]); + expect(second.readEvents()).toHaveLength(0); + }); + + it.each(INVALID_INTERNAL_STATS_PATHS)( + '%s 통계 요청은 400을 반환하고 조회하지 않는다', + async (_, path) => { + const fixture = createState(); + const query = vi.spyOn(RateLimitMetricsStore.prototype, 'query'); + const limiter = new DailyRateLimiter(fixture.state); + + const response = await limiter.fetch(request(path)); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: expect.any(String) }); + expect(query).not.toHaveBeenCalled(); + }, + ); + + it('현재 KST 날짜와 다른 asOf는 409를 반환하고 조회하지 않는다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T03:00:00+09:00')); + const fixture = createState(); + const query = vi.spyOn(RateLimitMetricsStore.prototype, 'query'); + const limiter = new DailyRateLimiter(fixture.state); + + const response = await limiter.fetch( + request('/stats?from=2026-07-21&to=2026-07-21&asOf=2026-07-21'), + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: '통계 조회 기준 날짜가 만료되었습니다.' }); + expect(query).not.toHaveBeenCalled(); + }); + + it.each([ + ['GET /consume', '/consume', { method: 'GET' }], + ['GET /blocked-events', '/blocked-events', { method: 'GET' }], + ['POST /stats', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22', { method: 'POST' }], + ['알 수 없는 경로', '/unknown', { method: 'POST' }], + ])('%s는 일관된 JSON 404를 반환한다', async (_, path, init) => { + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + + const response = await limiter.fetch(request(path, init)); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: '요청한 작업을 찾을 수 없습니다.' }); + expect(fixture.sql.executed).toHaveLength(0); + expect(fixture.get).not.toHaveBeenCalled(); + }); + + it('원장 기록 실패를 성공 응답으로 바꾸지 않는다', async () => { + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + vi.spyOn(RateLimitMetricsStore.prototype, 'record').mockRejectedValueOnce( + new Error('ledger unavailable'), + ); + + await expect( + limiter.fetch(request('/blocked-events', { method: 'POST', body: eventBody() })), + ).rejects.toThrow('ledger unavailable'); + }); + + it('통계 조회 실패를 0 집계로 바꾸지 않는다', async () => { + const fixture = createState(); + const limiter = new DailyRateLimiter(fixture.state); + vi.spyOn(RateLimitMetricsStore.prototype, 'query').mockRejectedValueOnce( + new Error('query unavailable'), + ); + + await expect( + limiter.fetch(request('/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22')), + ).rejects.toThrow('query unavailable'); + }); + + it('alarm은 cleanup 성공 후 ensureAlarm을 호출한다', async () => { + const fixture = createState(); + const cleanup = vi.spyOn(RateLimitMetricsStore.prototype, 'cleanup').mockResolvedValue(); + const ensureAlarm = vi + .spyOn(RateLimitMetricsStore.prototype, 'ensureAlarm') + .mockResolvedValue(); + const limiter = new DailyRateLimiter(fixture.state); + + await limiter.alarm(); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(ensureAlarm).toHaveBeenCalledOnce(); + expect(cleanup.mock.invocationCallOrder[0]).toBeLessThan( + ensureAlarm.mock.invocationCallOrder[0], + ); + }); + + it('alarm cleanup 실패를 전파하고 다음 alarm을 예약하지 않는다', async () => { + const fixture = createState(); + const cleanup = vi + .spyOn(RateLimitMetricsStore.prototype, 'cleanup') + .mockRejectedValueOnce(new Error('cleanup unavailable')); + const ensureAlarm = vi.spyOn(RateLimitMetricsStore.prototype, 'ensureAlarm'); + const limiter = new DailyRateLimiter(fixture.state); + + await expect(limiter.alarm()).rejects.toThrow('cleanup unavailable'); + expect(cleanup).toHaveBeenCalledOnce(); + expect(ensureAlarm).not.toHaveBeenCalled(); + }); + + it('alarm 예약 실패를 전파해 플랫폼 재시도를 허용한다', async () => { + const fixture = createState(); + const cleanup = vi.spyOn(RateLimitMetricsStore.prototype, 'cleanup').mockResolvedValue(); + const ensureAlarm = vi + .spyOn(RateLimitMetricsStore.prototype, 'ensureAlarm') + .mockRejectedValueOnce(new Error('alarm unavailable')); + const limiter = new DailyRateLimiter(fixture.state); + + await expect(limiter.alarm()).rejects.toThrow('alarm unavailable'); + expect(cleanup).toHaveBeenCalledOnce(); + expect(ensureAlarm).toHaveBeenCalledOnce(); + }); }); describe('KST 일자 계산', () => { diff --git a/tests/durableObjects/rateLimitMetricsStore.test.ts b/tests/durableObjects/rateLimitMetricsStore.test.ts new file mode 100644 index 0000000..0dcd855 --- /dev/null +++ b/tests/durableObjects/rateLimitMetricsStore.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + RATE_LIMIT_METRICS_LEDGER_NAME, + RATE_LIMIT_METRICS_RETENTION_DAYS, + RATE_LIMIT_SERVICES, + RateLimitMetricsStore, + type BlockedRateLimitEvent, + type RateLimitService, +} from '../../src/durableObjects/rateLimitMetricsStore.js'; +import { + HAS_NATIVE_SQLITE, + createRateLimitSqlState as createState, +} from '../helpers/rateLimitSqlStorage.js'; + +function event( + eventId: string, + day: string, + service: RateLimitService, + identityId: string, +): BlockedRateLimitEvent { + return { + eventId, + occurredAt: Date.parse(`${day}T03:00:00+09:00`), + day, + service, + identityId, + }; +} + +describe('RateLimitMetricsStore', () => { + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T03:00:00+09:00')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('공개 상수와 SQLite 테이블 및 집계 인덱스를 초기화한다', () => { + const fixture = createState(); + + new RateLimitMetricsStore(fixture.state); + + expect(RATE_LIMIT_METRICS_RETENTION_DAYS).toBe(30); + expect(RATE_LIMIT_METRICS_LEDGER_NAME).toBe('__blocked-ledger-v1__'); + expect(RATE_LIMIT_SERVICES).toEqual(['oliveyoung', 'cgv', 'cu', 'gs25', 'lottemart']); + expect(fixture.storageKind).toBe(HAS_NATIVE_SQLITE ? 'node:sqlite' : 'memory'); + expect(fixture.sql.executed.map(({ query }) => query)).toEqual([ + expect.stringContaining('CREATE TABLE IF NOT EXISTS blocked_events'), + expect.stringContaining('ON blocked_events(day, service)'), + expect.stringContaining('ON blocked_events(day, identity_id)'), + expect.stringContaining('ON blocked_events(day, service, identity_id)'), + ]); + expect(() => fixture.sql.exec('MALFORMED SQL')).toThrow(); + }); + + it('기존 alarm을 재설정하지 않고 이벤트를 저장하며 event_id 중복은 한 번만 센다', async () => { + const fixture = createState({ alarm: Date.parse('2026-07-22T15:05:00Z') }); + const store = new RateLimitMetricsStore(fixture.state); + const blockedEvent = event('event-1', '2026-07-22', 'cgv', 'opaque-id-1'); + + await store.record(blockedEvent); + await store.record(blockedEvent); + + const insert = fixture.sql.executed.find(({ query }) => query.startsWith('INSERT OR IGNORE')); + expect(insert).toEqual({ + query: expect.stringContaining('blocked_events'), + bindings: ['event-1', blockedEvent.occurredAt, '2026-07-22', 'cgv', 'opaque-id-1'], + }); + await expect( + store.query({ from: '2026-07-22', to: '2026-07-22', asOfDay: '2026-07-22' }), + ).resolves.toMatchObject({ totals: { blockedRequests: 1, uniqueIdentities: 1 } }); + expect(fixture.getAlarm).toHaveBeenCalledTimes(2); + expect(fixture.setAlarm).not.toHaveBeenCalled(); + }); + + it('alarm 예약 실패 시 cleanup과 INSERT 전에 거부하고 민감값을 기록하지 않는다', async () => { + const fixture = createState({ setAlarmError: new Error('alarm unavailable') }); + const store = new RateLimitMetricsStore(fixture.state); + const blockedEvent = event('event-1', '2026-07-22', 'cgv', 'opaque-id-1'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const statementsBeforeRecord = fixture.sql.executed.length; + + await expect(store.record(blockedEvent)).rejects.toThrow('alarm unavailable'); + + expect(fixture.sql.executed).toHaveLength(statementsBeforeRecord); + expect(fixture.readEvents()).toHaveLength(0); + await expect( + store.query({ from: '2026-07-22', to: '2026-07-22', asOfDay: '2026-07-22' }), + ).resolves.toMatchObject({ totals: { blockedRequests: 0, uniqueIdentities: 0 } }); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('KST 발생 날짜와 일치하지 않는 이벤트는 저장하지 않는다', async () => { + const fixture = createState(); + const store = new RateLimitMetricsStore(fixture.state); + const mismatched = event('event-1', '2026-07-22', 'cgv', 'opaque-id-1'); + mismatched.occurredAt = Date.parse('2026-07-21T14:59:59Z'); + + await expect(store.record(mismatched)).rejects.toThrow('KST 발생 날짜'); + expect(fixture.readEvents()).toHaveLength(0); + }); + + it('alarm 대기 중 KST 날짜가 바뀌면 cleanup과 INSERT 전에 stale 이벤트를 거부한다', async () => { + const beforeMidnight = Date.parse('2026-07-22T14:59:59.999Z'); + const afterMidnight = Date.parse('2026-07-22T15:00:00.000Z'); + vi.mocked(Date.now).mockReturnValue(beforeMidnight); + const fixture = createState(); + const store = new RateLimitMetricsStore(fixture.state); + let releaseAlarm: (() => void) | undefined; + const alarmGate = new Promise((resolve) => { + releaseAlarm = resolve; + }); + const ensureAlarm = vi.spyOn(store, 'ensureAlarm').mockReturnValue(alarmGate); + const statementsBeforeRecord = fixture.sql.executed.length; + const recordPromise = store.record({ + ...event('rollover-event', '2026-07-22', 'cgv', 'opaque-id'), + occurredAt: beforeMidnight, + }); + await vi.waitFor(() => expect(ensureAlarm).toHaveBeenCalledOnce()); + + vi.mocked(Date.now).mockReturnValue(afterMidnight); + releaseAlarm?.(); + + await expect(recordPromise).rejects.toMatchObject({ name: 'StaleRateLimitEventDayError' }); + expect(fixture.sql.executed).toHaveLength(statementsBeforeRecord); + expect(fixture.readEvents()).toHaveLength(0); + }); + + it('KST 자정 직전의 현재 이벤트는 정상 기록한다', async () => { + const beforeMidnight = Date.parse('2026-07-22T14:59:59.999Z'); + vi.mocked(Date.now).mockReturnValue(beforeMidnight); + const fixture = createState({ alarm: 1 }); + const store = new RateLimitMetricsStore(fixture.state); + const blockedEvent = { + ...event('before-midnight', '2026-07-22', 'cgv', 'opaque-id'), + occurredAt: beforeMidnight, + }; + + await store.record(blockedEvent); + + expect(fixture.readEvents()).toEqual([blockedEvent]); + }); + + it('반복 호출 주체와 서비스별 호출 주체를 정확하고 결정적인 순서로 집계한다', async () => { + const fixture = createState({ alarm: 1 }); + const store = new RateLimitMetricsStore(fixture.state); + const events = [ + event('event-5', '2026-07-22', 'gs25', 'shared-id'), + event('event-3', '2026-07-20', 'oliveyoung', 'olive-id'), + event('event-2', '2026-07-22', 'cgv', 'shared-id'), + event('event-4', '2026-07-21', 'cu', 'cu-id'), + event('event-1', '2026-07-22', 'cgv', 'shared-id'), + ]; + for (const blockedEvent of events) { + vi.mocked(Date.now).mockReturnValue(blockedEvent.occurredAt); + await store.record(blockedEvent); + } + + const stats = await store.query({ + from: '2026-07-20', + to: '2026-07-22', + asOfDay: '2026-07-22', + }); + + expect(stats).toEqual({ + totals: { blockedRequests: 5, uniqueIdentities: 3 }, + daily: [ + { day: '2026-07-20', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-21', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', blockedRequests: 3, uniqueIdentities: 1 }, + ], + services: [ + { + day: '2026-07-20', + service: 'oliveyoung', + blockedRequests: 1, + uniqueIdentities: 1, + }, + { day: '2026-07-21', service: 'cu', blockedRequests: 1, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'cgv', blockedRequests: 2, uniqueIdentities: 1 }, + { day: '2026-07-22', service: 'gs25', blockedRequests: 1, uniqueIdentities: 1 }, + ], + }); + expect(JSON.stringify(stats)).not.toContain('shared-id'); + expect(JSON.stringify(stats)).not.toContain('olive-id'); + expect(JSON.stringify(stats)).not.toContain('cu-id'); + }); + + it('서비스 필터를 합계와 모든 행에 적용하고 빈 기간은 0 집계를 반환한다', async () => { + const fixture = createState({ alarm: 1 }); + const store = new RateLimitMetricsStore(fixture.state); + await store.record(event('event-1', '2026-07-22', 'cgv', 'shared-id')); + await store.record(event('event-2', '2026-07-22', 'gs25', 'shared-id')); + + await expect( + store.query({ + from: '2026-07-22', + to: '2026-07-22', + asOfDay: '2026-07-22', + service: 'cgv', + }), + ).resolves.toEqual({ + totals: { blockedRequests: 1, uniqueIdentities: 1 }, + daily: [{ day: '2026-07-22', blockedRequests: 1, uniqueIdentities: 1 }], + services: [{ day: '2026-07-22', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }], + }); + await expect( + store.query({ from: '2026-07-01', to: '2026-07-01', asOfDay: '2026-07-22' }), + ).resolves.toEqual({ + totals: { blockedRequests: 0, uniqueIdentities: 0 }, + daily: [], + services: [], + }); + }); + + it('정리 backlog가 있어도 현재 KST 보관 기간보다 오래된 행은 집계하지 않는다', async () => { + const expired = Array.from({ length: 2_001 }, (_, index) => + event(`expired-${index}`, '2026-01-01', 'cgv', `expired-id-${index}`), + ); + const fixture = createState({ alarm: 1 }); + const store = new RateLimitMetricsStore(fixture.state); + fixture.seed([...expired, event('current', '2026-07-22', 'cgv', 'current-id')]); + + await expect( + store.query({ from: '2026-01-01', to: '2026-07-22', asOfDay: '2026-07-22' }), + ).resolves.toEqual({ + totals: { blockedRequests: 1, uniqueIdentities: 1 }, + daily: [{ day: '2026-07-22', blockedRequests: 1, uniqueIdentities: 1 }], + services: [{ day: '2026-07-22', service: 'cgv', blockedRequests: 1, uniqueIdentities: 1 }], + }); + await expect( + store.query({ from: '2026-01-01', to: '2026-01-31', asOfDay: '2026-07-22' }), + ).resolves.toEqual({ + totals: { blockedRequests: 0, uniqueIdentities: 0 }, + daily: [], + services: [], + }); + }); + + it('조회 중 다음 KST 날짜가 되어도 전달받은 asOf 보관 경계로 집계한 뒤 물리 정리한다', async () => { + vi.mocked(Date.now).mockReturnValue(Date.parse('2026-07-22T15:00:00.000Z')); + const fixture = createState({ alarm: 1 }); + const store = new RateLimitMetricsStore(fixture.state); + fixture.seed([ + event('as-of-boundary', '2026-06-23', 'cgv', 'boundary-id'), + event('as-of-current', '2026-07-22', 'cgv', 'current-id'), + ]); + + const stats = await store.query({ + from: '2026-06-23', + to: '2026-07-22', + asOfDay: '2026-07-22', + }); + + expect(stats.totals).toEqual({ blockedRequests: 2, uniqueIdentities: 2 }); + expect(stats.daily[0]?.day).toBe('2026-06-23'); + expect(fixture.readEvents().map(({ eventId }) => eventId)).toEqual(['as-of-current']); + }); + + it('허용 목록에 없는 서비스 필터는 SQL 실행 전에 거부한다', async () => { + const fixture = createState(); + const store = new RateLimitMetricsStore(fixture.state); + const beforeQuery = fixture.sql.executed.length; + + await expect( + store.query({ + from: '2026-07-22', + to: '2026-07-22', + asOfDay: '2026-07-22', + service: 'daiso' as RateLimitService, + }), + ).rejects.toThrow('지원하지 않는 서비스'); + expect(fixture.sql.executed).toHaveLength(beforeQuery); + }); + + it('KST 현재 날짜를 포함한 정확히 30개 날짜를 보존한다', async () => { + const first = createState(); + const firstStore = new RateLimitMetricsStore(first.state); + first.seed([ + event('expired-before-midnight', '2026-06-22', 'cgv', 'old'), + event('boundary-before-midnight', '2026-06-23', 'cgv', 'keep'), + ]); + + await firstStore.cleanup(Date.parse('2026-07-22T14:59:59.999Z')); + + expect(first.readEvents().map(({ eventId }) => eventId)).toEqual(['boundary-before-midnight']); + + const second = createState(); + const secondStore = new RateLimitMetricsStore(second.state); + second.seed([ + event('expired-after-midnight', '2026-06-23', 'cgv', 'old'), + event('boundary-after-midnight', '2026-06-24', 'cgv', 'keep'), + ]); + + await secondStore.cleanup(Date.parse('2026-07-22T15:00:00.000Z')); + + expect(second.readEvents().map(({ eventId }) => eventId)).toEqual(['boundary-after-midnight']); + }); + + it('기록 시 오래된 행 정리를 한 번에 제한한다', async () => { + const oldEvents = Array.from({ length: 101 }, (_, index) => + event(`old-${index.toString().padStart(4, '0')}`, '2026-01-01', 'cgv', `id-${index}`), + ); + const fixture = createState({ alarm: 1 }); + const store = new RateLimitMetricsStore(fixture.state); + fixture.seed(oldEvents); + + await store.record(event('current', '2026-07-22', 'cgv', 'current-id')); + + expect(fixture.readEvents().filter(({ day }) => day === '2026-01-01')).toHaveLength(1); + expect(fixture.readEvents().some(({ eventId }) => eventId === 'current')).toBe(true); + }); + + it('기존 alarm은 유지하고 없을 때만 다음 KST 자정 5분 후를 예약한다', async () => { + const existing = createState({ alarm: Date.parse('2026-07-23T15:05:00Z') }); + const existingStore = new RateLimitMetricsStore(existing.state); + await existingStore.ensureAlarm(Date.parse('2026-07-22T14:59:59Z')); + expect(existing.setAlarm).not.toHaveBeenCalled(); + + const missing = createState(); + const missingStore = new RateLimitMetricsStore(missing.state); + await missingStore.ensureAlarm(Date.parse('2026-07-22T15:00:00Z')); + await missingStore.ensureAlarm(Date.parse('2026-07-22T15:01:00Z')); + expect(missing.setAlarm).toHaveBeenCalledOnce(); + expect(missing.setAlarm).toHaveBeenCalledWith(Date.parse('2026-07-23T15:05:00Z')); + }); +}); diff --git a/tests/helpers/dailyRateLimitFixture.ts b/tests/helpers/dailyRateLimitFixture.ts new file mode 100644 index 0000000..6d90a4f --- /dev/null +++ b/tests/helpers/dailyRateLimitFixture.ts @@ -0,0 +1,139 @@ +import { vi } from 'vitest'; +import type { AppBindings } from '../../src/api/response.js'; +import { + nextKstMidnightEpochSeconds, + type DailyRateLimitResult, +} from '../../src/durableObjects/dailyRateLimiter.js'; +import { RATE_LIMIT_METRICS_LEDGER_NAME } from '../../src/durableObjects/rateLimitMetricsStore.js'; + +export const ORIGINAL_CACHES = (globalThis as { caches?: CacheStorage }).caches; +export const QUOTA_ID = '1'.repeat(64); +export const LEDGER_ID = 'f'.repeat(64); +export const EVENT_ID = '018d6b61-b263-7f5c-8c2d-1c01b849eea7'; +export const NOW_MS = Date.parse('2026-07-22T00:01:02.345Z'); +export const KST_ROLLOVER_BEFORE_MS = Date.parse('2026-07-22T14:59:59.999Z'); +export const KST_ROLLOVER_AFTER_MS = Date.parse('2026-07-22T15:00:00.000Z'); +export const RATE_LIMIT_DAY = '2026-07-22'; + +export type LedgerOutcome = + 204 | 200 | 503 | 'throw' | 'malformed' | ((request: Request) => Response | Promise); + +export interface DurableObjectCall { + durableObjectId: string; + request: Request; +} + +export function createResult(overrides: Partial = {}): DailyRateLimitResult { + return { + allowed: true, + count: 1, + remaining: 2999, + resetAt: nextKstMidnightEpochSeconds(NOW_MS), + day: RATE_LIMIT_DAY, + ...overrides, + }; +} + +function createDurableObjectId(value: string): DurableObjectId { + return { toString: () => value } as unknown as DurableObjectId; +} + +function createQuotaResponse(value: unknown): Response { + const response = new Response(null, { status: 200 }); + Object.defineProperty(response, 'json', { value: async () => value }); + return response; +} + +export function createRateLimitEnv( + results: unknown[], + additional: Partial = {}, + ledgerOutcome: LedgerOutcome = 204, + afterQuotaResponse: () => void = () => undefined, +) { + const queue = [...results]; + const calls: DurableObjectCall[] = []; + const order: string[] = []; + const quotaIds = new Map(); + const ledgerId = createDurableObjectId(LEDGER_ID); + const idFromName = vi.fn((name: string) => { + if (name === RATE_LIMIT_METRICS_LEDGER_NAME) { + return ledgerId; + } + let id = quotaIds.get(name); + if (!id) { + id = createDurableObjectId( + (QUOTA_ID.slice(0, -1) + (quotaIds.size + 1).toString(16)).padStart(64, '0'), + ); + quotaIds.set(name, id); + } + return id; + }); + const stubFetch = vi.fn( + async (durableObjectId: string, input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + calls.push({ durableObjectId, request }); + if (new URL(request.url).pathname === '/consume') { + order.push('consume:started'); + const result = queue.length > 0 ? queue.shift() : createResult(); + const response = createQuotaResponse(result); + order.push('consume:resolved'); + afterQuotaResponse(); + return response; + } + + order.push('record:started'); + if (typeof ledgerOutcome === 'function') { + const response = await ledgerOutcome(request); + order.push('record:resolved'); + return response; + } + if (ledgerOutcome === 'throw') { + throw new Error('민감한 원장 오류 203.0.113.10'); + } + if (ledgerOutcome === 'malformed') { + return { status: 204 } as Response; + } + const response = new Response(ledgerOutcome === 204 ? null : 'ledger response', { + status: ledgerOutcome, + }); + order.push('record:resolved'); + return response; + }, + ); + const get = vi.fn((id: DurableObjectId) => ({ + fetch: (input: RequestInfo | URL, init?: RequestInit) => stubFetch(id.toString(), input, init), + })); + const env: AppBindings = { + DAILY_RATE_LIMITER: { get, idFromName } as unknown as DurableObjectNamespace, + ...additional, + }; + + return { env, idFromName, get, stubFetch, calls, order }; +} + +export function callPaths(calls: DurableObjectCall[]): string[] { + return calls.map(({ request }) => new URL(request.url).pathname); +} + +export const MALFORMED_QUOTA_RESULTS: ReadonlyArray = [ + ['빈 객체', {}], + ['null', null], + ['배열', []], + ['문자열 allowed', { ...createResult(), allowed: 'true' }], + ['NaN count', { ...createResult(), count: Number.NaN }], + ['소수 count', { ...createResult(), count: 1.5 }], + ['음수 count', { ...createResult(), count: -1 }], + ['한도 초과 count', { ...createResult(), count: 3001 }], + ['NaN remaining', { ...createResult(), remaining: Number.NaN }], + ['소수 remaining', { ...createResult(), remaining: 1.5 }], + ['음수 remaining', { ...createResult(), remaining: -1 }], + ['한도 초과 remaining', { ...createResult(), remaining: 3001 }], + ['NaN resetAt', { ...createResult(), resetAt: Number.NaN }], + ['소수 resetAt', { ...createResult(), resetAt: 1.5 }], + ['음수 resetAt', { ...createResult(), resetAt: -1 }], + ['safe integer 초과 resetAt', { ...createResult(), resetAt: Number.MAX_SAFE_INTEGER + 1 }], + ['잘못된 날짜 형식', { ...createResult(), day: '2026/07/17' }], + ['존재하지 않는 날짜', { ...createResult(), day: '2026-02-29' }], + ['허용 결과의 불일치한 remaining', { ...createResult(), remaining: 0 }], + ['거부 결과의 불일치한 count', { ...createResult(), allowed: false, count: 2999, remaining: 1 }], +]; diff --git a/tests/helpers/dailyRateLimiterStatsCases.ts b/tests/helpers/dailyRateLimiterStatsCases.ts new file mode 100644 index 0000000..5492ab5 --- /dev/null +++ b/tests/helpers/dailyRateLimiterStatsCases.ts @@ -0,0 +1,16 @@ +export const INVALID_INTERNAL_STATS_PATHS = [ + ['from 누락', '/stats?to=2026-07-22&asOf=2026-07-22'], + ['to 누락', '/stats?from=2026-07-22&asOf=2026-07-22'], + ['asOf 누락', '/stats?from=2026-07-21&to=2026-07-22'], + ['정확하지 않은 from', '/stats?from=2026-7-21&to=2026-07-22&asOf=2026-07-22'], + ['존재하지 않는 to', '/stats?from=2026-02-01&to=2026-02-30&asOf=2026-07-22'], + ['역전된 기간', '/stats?from=2026-07-23&to=2026-07-22&asOf=2026-07-22'], + ['빈 서비스', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22&service='], + ['지원하지 않는 서비스', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22&service=daiso'], + ['중복 from', '/stats?from=2026-07-21&from=2026-07-22&to=2026-07-22&asOf=2026-07-22'], + ['중복 to', '/stats?from=2026-07-21&to=2026-07-21&to=2026-07-22&asOf=2026-07-22'], + ['중복 서비스', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22&service=cgv&service=gs25'], + ['중복 asOf', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22&asOf=2026-07-22'], + ['존재하지 않는 asOf', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-02-30'], + ['알 수 없는 키', '/stats?from=2026-07-21&to=2026-07-22&asOf=2026-07-22&identityId=opaque'], +] as const; diff --git a/tests/helpers/rateLimitSqlStorage.ts b/tests/helpers/rateLimitSqlStorage.ts new file mode 100644 index 0000000..4a7debe --- /dev/null +++ b/tests/helpers/rateLimitSqlStorage.ts @@ -0,0 +1,390 @@ +import { vi } from 'vitest'; + +type SqlRow = Record; +type NativeSqlValue = string | number | bigint | null | Uint8Array; + +interface NativeStatement { + all(...bindings: NativeSqlValue[]): Record[]; + columns(): Array<{ name: string }>; + run(...bindings: NativeSqlValue[]): { changes: number | bigint }; +} + +interface NativeDatabase { + prepare(query: string): NativeStatement; +} + +type NativeDatabaseConstructor = new (path: string) => NativeDatabase; + +let NativeDatabaseSync: NativeDatabaseConstructor | undefined; +try { + const moduleName = 'node:sqlite'; + const sqliteModule = (await import(moduleName)) as unknown as { + DatabaseSync: NativeDatabaseConstructor; + }; + NativeDatabaseSync = sqliteModule.DatabaseSync; +} catch { + NativeDatabaseSync = undefined; +} + +export const HAS_NATIVE_SQLITE = NativeDatabaseSync !== undefined; + +export interface StoredEvent { + eventId: string; + occurredAt: number; + day: string; + service: string; + identityId: string; +} + +interface ExecutedSql { + query: string; + bindings: SqlStorageValue[]; +} + +class TestSqlCursor implements SqlStorageCursor { + readonly rowsRead: number; + private index = 0; + + constructor( + private readonly rows: T[], + readonly rowsWritten = 0, + readonly columnNames: string[] = rows[0] ? Object.keys(rows[0]) : [], + ) { + this.rowsRead = rows.length; + } + + next(): { done?: false; value: T } | { done: true; value?: never } { + const value = this.rows[this.index++]; + return value === undefined ? { done: true } : { done: false, value }; + } + + toArray(): T[] { + return [...this]; + } + + one(): T { + const rows = this.toArray(); + if (rows.length !== 1) { + throw new Error(`한 행이 필요하지만 ${rows.length}개를 받았습니다.`); + } + return rows[0]; + } + + *raw(): IterableIterator { + for (const row of this) { + yield Object.values(row) as unknown as U; + } + } + + *[Symbol.iterator](): IterableIterator { + let result = this.next(); + while (!result.done) { + yield result.value; + result = this.next(); + } + } +} + +interface TestSqlStorage { + readonly executed: ExecutedSql[]; + exec(query: string, ...bindings: any[]): SqlStorageCursor; + readEvents(): StoredEvent[]; + seed(events: StoredEvent[]): void; +} + +function normalize(query: string): string { + return query.replace(/\s+/g, ' ').trim(); +} + +function toNativeBinding(binding: SqlStorageValue): NativeSqlValue { + return binding instanceof ArrayBuffer ? new Uint8Array(binding) : binding; +} + +class NativeSqlStorage implements TestSqlStorage { + readonly executed: ExecutedSql[] = []; + + constructor(private readonly database: NativeDatabase) {} + + exec(query: string, ...bindings: any[]): SqlStorageCursor { + const sqlBindings = bindings as SqlStorageValue[]; + this.executed.push({ query: normalize(query), bindings: sqlBindings }); + const statement = this.database.prepare(query); + const columnNames = statement.columns().map(({ name }) => name); + const nativeBindings = sqlBindings.map(toNativeBinding); + + if (columnNames.length > 0) { + const rows = statement.all(...nativeBindings) as unknown as T[]; + return new TestSqlCursor(rows, 0, columnNames); + } + + const result = statement.run(...nativeBindings); + return new TestSqlCursor([], Number(result.changes), columnNames); + } + + seed(events: StoredEvent[]): void { + const statement = this.database.prepare( + `INSERT INTO blocked_events (event_id, occurred_at, day, service, identity_id) + VALUES (?, ?, ?, ?, ?)`, + ); + for (const event of events) { + statement.run(event.eventId, event.occurredAt, event.day, event.service, event.identityId); + } + } + + readEvents(): StoredEvent[] { + const rows = this.database + .prepare( + `SELECT event_id, occurred_at, day, service, identity_id + FROM blocked_events + ORDER BY event_id ASC`, + ) + .all(); + return rows.map((row) => ({ + eventId: String(row.event_id), + occurredAt: Number(row.occurred_at), + day: String(row.day), + service: String(row.service), + identityId: String(row.identity_id), + })); + } +} + +const CREATE_TABLE = normalize(`CREATE TABLE IF NOT EXISTS blocked_events ( + event_id TEXT PRIMARY KEY, + occurred_at INTEGER NOT NULL, + day TEXT NOT NULL, + service TEXT NOT NULL, + identity_id TEXT NOT NULL +)`); +const INDEXES = new Set([ + 'CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service ON blocked_events(day, service)', + 'CREATE INDEX IF NOT EXISTS idx_blocked_events_day_identity ON blocked_events(day, identity_id)', + 'CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service_identity ON blocked_events(day, service, identity_id)', +]); +const INSERT = normalize(`INSERT OR IGNORE INTO blocked_events + (event_id, occurred_at, day, service, identity_id) + VALUES (?, ?, ?, ?, ?)`); +const DELETE_BATCH = normalize(`DELETE FROM blocked_events + WHERE event_id IN ( + SELECT event_id + FROM blocked_events + WHERE day < ? + ORDER BY day ASC, event_id ASC + LIMIT ? + )`); +const DELETE_ALL = 'DELETE FROM blocked_events WHERE day < ?'; +const TOTAL_SELECT = normalize(`SELECT COUNT(*) AS blocked_requests, + COUNT(DISTINCT identity_id) AS unique_identities + FROM blocked_events + WHERE day >= ? AND day <= ? AND day >= ?`); +const DAILY_SELECT = normalize(`SELECT day, + COUNT(*) AS blocked_requests, + COUNT(DISTINCT identity_id) AS unique_identities + FROM blocked_events + WHERE day >= ? AND day <= ? AND day >= ? + GROUP BY day + ORDER BY day ASC`); +const SERVICE_SELECT = normalize(`SELECT day, + service, + COUNT(*) AS blocked_requests, + COUNT(DISTINCT identity_id) AS unique_identities + FROM blocked_events + WHERE day >= ? AND day <= ? AND day >= ? + GROUP BY day, service + ORDER BY day ASC, service ASC`); + +function withServiceFilter(query: string): string { + return query.includes(' GROUP BY') + ? query.replace(' GROUP BY', ' AND service = ? GROUP BY') + : query.replace( + ' WHERE day >= ? AND day <= ? AND day >= ?', + ' WHERE day >= ? AND day <= ? AND day >= ? AND service = ?', + ); +} + +function assertBindingCount(bindings: SqlStorageValue[], expected: number): void { + if (bindings.length !== expected) { + throw new Error(`SQL 바인딩은 ${expected}개가 필요합니다.`); + } +} + +function groupEvents( + events: StoredEvent[], + keyOf: (event: StoredEvent) => string, +): StoredEvent[][] { + const groups = new Map(); + for (const event of events) { + const key = keyOf(event); + groups.set(key, [...(groups.get(key) ?? []), event]); + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, group]) => group); +} + +class MemorySqlStorage implements TestSqlStorage { + readonly executed: ExecutedSql[] = []; + private readonly events = new Map(); + + exec(query: string, ...bindings: any[]): SqlStorageCursor { + const normalized = normalize(query); + const sqlBindings = bindings as SqlStorageValue[]; + this.executed.push({ query: normalized, bindings: sqlBindings }); + + if (normalized === CREATE_TABLE || INDEXES.has(normalized)) { + assertBindingCount(sqlBindings, 0); + return new TestSqlCursor([]); + } + if (normalized === INSERT) { + return this.insert(sqlBindings); + } + if (normalized === DELETE_BATCH) { + return this.deleteBatch(sqlBindings); + } + if (normalized === DELETE_ALL) { + return this.deleteAll(sqlBindings); + } + + const select = this.select(normalized, sqlBindings); + if (select) { + return select; + } + throw new Error(`지원하지 않는 SQL: ${normalized}`); + } + + seed(events: StoredEvent[]): void { + for (const event of events) { + if (this.events.has(event.eventId)) { + throw new Error('UNIQUE constraint failed: blocked_events.event_id'); + } + this.events.set(event.eventId, { ...event }); + } + } + + readEvents(): StoredEvent[] { + return [...this.events.values()] + .sort((left, right) => left.eventId.localeCompare(right.eventId)) + .map((event) => ({ ...event })); + } + + private insert(bindings: SqlStorageValue[]): SqlStorageCursor { + assertBindingCount(bindings, 5); + const [eventId, occurredAt, day, service, identityId] = bindings as [ + string, + number, + string, + string, + string, + ]; + const existed = this.events.has(eventId); + if (!existed) { + this.events.set(eventId, { eventId, occurredAt, day, service, identityId }); + } + return new TestSqlCursor([], existed ? 0 : 1); + } + + private deleteBatch(bindings: SqlStorageValue[]): SqlStorageCursor { + assertBindingCount(bindings, 2); + const [cutoff, limit] = bindings as [string, number]; + const expired = [...this.events.values()] + .filter((event) => event.day < cutoff) + .sort((left, right) => + left.day === right.day + ? left.eventId.localeCompare(right.eventId) + : left.day.localeCompare(right.day), + ) + .slice(0, limit); + for (const event of expired) { + this.events.delete(event.eventId); + } + return new TestSqlCursor([], expired.length); + } + + private deleteAll(bindings: SqlStorageValue[]): SqlStorageCursor { + assertBindingCount(bindings, 1); + const [cutoff] = bindings as [string]; + const expired = [...this.events.values()].filter((event) => event.day < cutoff); + for (const event of expired) { + this.events.delete(event.eventId); + } + return new TestSqlCursor([], expired.length); + } + + private select( + query: string, + bindings: SqlStorageValue[], + ): SqlStorageCursor | undefined { + const unfiltered = [TOTAL_SELECT, DAILY_SELECT, SERVICE_SELECT]; + const filtered = unfiltered.map(withServiceFilter); + const queryIndex = unfiltered.indexOf(query); + const filteredIndex = filtered.indexOf(query); + if (queryIndex === -1 && filteredIndex === -1) { + return undefined; + } + + const hasService = filteredIndex !== -1; + assertBindingCount(bindings, hasService ? 4 : 3); + const [from, to, cutoff, service] = bindings as [string, string, string, string?]; + const events = [...this.events.values()].filter( + (event) => + event.day >= from && + event.day <= to && + event.day >= cutoff && + (!hasService || event.service === service), + ); + const selectIndex = hasService ? filteredIndex : queryIndex; + const rows = this.aggregate(events, selectIndex); + return new TestSqlCursor(rows as T[]); + } + + private aggregate(events: StoredEvent[], selectIndex: number): SqlRow[] { + if (selectIndex === 0) { + return [ + { + blocked_requests: events.length, + unique_identities: new Set(events.map((event) => event.identityId)).size, + }, + ]; + } + + const groups = groupEvents( + events, + selectIndex === 1 ? (event) => event.day : (event) => `${event.day}\0${event.service}`, + ); + return groups.map((group) => ({ + day: group[0].day, + ...(selectIndex === 2 ? { service: group[0].service } : {}), + blocked_requests: group.length, + unique_identities: new Set(group.map((event) => event.identityId)).size, + })); + } +} + +export function createRateLimitSqlState( + options: { alarm?: number | null; setAlarmError?: Error } = {}, +) { + const sql: TestSqlStorage = NativeDatabaseSync + ? new NativeSqlStorage(new NativeDatabaseSync(':memory:')) + : new MemorySqlStorage(); + let alarm = options.alarm ?? null; + const getAlarm = vi.fn(async () => alarm); + const setAlarm = vi.fn(async (scheduledTime: number | Date) => { + if (options.setAlarmError) { + throw options.setAlarmError; + } + alarm = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime; + }); + const state = { + storage: { sql, getAlarm, setAlarm }, + } as unknown as DurableObjectState; + + return { + state, + sql, + getAlarm, + setAlarm, + seed: (events: StoredEvent[]) => sql.seed(events), + readEvents: () => sql.readEvents(), + storageKind: HAS_NATIVE_SQLITE ? 'node:sqlite' : 'memory', + }; +} diff --git a/tests/middleware/dailyRateLimit.test.ts b/tests/middleware/dailyRateLimit.test.ts index ce4cf15..65f1439 100644 --- a/tests/middleware/dailyRateLimit.test.ts +++ b/tests/middleware/dailyRateLimit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { consumeDailyRateLimit, hashRateLimitIdentity, @@ -6,36 +6,66 @@ import { setRateLimitHeaders, } from '../../src/middleware/dailyRateLimit.js'; import type { AppBindings } from '../../src/api/response.js'; -import type { DailyRateLimitResult } from '../../src/durableObjects/dailyRateLimiter.js'; +import { + nextKstMidnightEpochSeconds, + type DailyRateLimitResult, +} from '../../src/durableObjects/dailyRateLimiter.js'; + +const NOW_MS = Date.parse('2026-07-17T00:00:00.000Z'); const allowedResult: DailyRateLimitResult = { allowed: true, count: 1, remaining: 2999, - resetAt: 1784296800, + resetAt: nextKstMidnightEpochSeconds(NOW_MS), day: '2026-07-17', }; -function createRateLimitEnv(response: Response | Error = Response.json(allowedResult)) { - const stubFetch = vi.fn(async () => { - if (response instanceof Error) { - throw response; +const FIRST_QUOTA_ID = '1'.repeat(64); + +type QuotaOutcome = DailyRateLimitResult | Error | (() => Response); + +function createDurableObjectId(value: string): DurableObjectId { + return { toString: () => value } as unknown as DurableObjectId; +} + +function createRateLimitEnv(outcome: QuotaOutcome = allowedResult) { + const quotaIds = new Map(); + const requests: Request[] = []; + const stubFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request); + if (outcome instanceof Error) { + throw outcome; } - return response; + return typeof outcome === 'function' ? outcome() : Response.json(outcome); }); const get = vi.fn(() => ({ fetch: stubFetch })); - const idFromName = vi.fn((name: string) => ({ name }) as unknown as DurableObjectId); + const idFromName = vi.fn((name: string) => { + let id = quotaIds.get(name); + if (!id) { + id = createDurableObjectId( + (FIRST_QUOTA_ID.slice(0, -1) + (quotaIds.size + 1).toString(16)).padStart(64, '0'), + ); + quotaIds.set(name, id); + } + return id; + }); const env: AppBindings = { DAILY_RATE_LIMITER: { get, idFromName } as unknown as DurableObjectNamespace, }; - return { env, idFromName, get, stubFetch }; + return { env, idFromName, get, stubFetch, requests }; } afterEach(() => { vi.restoreAllMocks(); }); +beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(NOW_MS); +}); + describe('isDailyRateLimitedRequest', () => { it.each([ '/api/oliveyoung/products', @@ -54,6 +84,11 @@ describe('isDailyRateLimitedRequest', () => { '/', '/api/daiso/products', '/api/oliveyoungness/products', + '/api/cgvish/timetable', + '/api/cuish/stores', + '/api/gs250/products', + '/api/lottemartish/products', + '/api/cgv', ])('%s 경로를 제외한다', (path) => { expect(isDailyRateLimitedRequest(new Request(`https://example.com${path}`))).toBe(false); }); @@ -81,9 +116,13 @@ describe('consumeDailyRateLimit', () => { expect(result).toEqual(allowedResult); expect(fixture.idFromName).toHaveBeenCalledWith(await hashRateLimitIdentity('203.0.113.10')); expect(fixture.idFromName).not.toHaveBeenCalledWith('203.0.113.10'); + expect(fixture.get.mock.calls[0]?.[0].toString()).toBe(FIRST_QUOTA_ID); expect(fixture.stubFetch).toHaveBeenCalledWith('https://daily-rate-limit/consume', { method: 'POST', }); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]?.method).toBe('POST'); + expect(new URL(fixture.requests[0]?.url ?? '').pathname).toBe('/consume'); }); it('교차 존 Worker 요청을 upstream zone별 객체로 분리한다', async () => { @@ -242,7 +281,7 @@ describe('consumeDailyRateLimit', () => { it('Durable Object 오류나 비정상 응답이면 fail-open한다', async () => { const errorFixture = createRateLimitEnv(new Error('temporary failure')); - const badResponseFixture = createRateLimitEnv(new Response('bad', { status: 503 })); + const badResponseFixture = createRateLimitEnv(() => new Response('bad', { status: 503 })); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); const request = new Request('https://example.com/api/cgv/timetable', { headers: { 'CF-Connecting-IP': '203.0.113.10' }, @@ -250,23 +289,29 @@ describe('consumeDailyRateLimit', () => { expect(await consumeDailyRateLimit(request, errorFixture.env)).toBeNull(); expect(await consumeDailyRateLimit(request, badResponseFixture.env)).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith('일일 호출 제한 확인 실패', 'temporary failure'); + expect(consoleSpy).toHaveBeenCalled(); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('temporary failure'); + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('203.0.113.10'); }); - it('Error가 아닌 예외도 민감정보 없이 기록하고 fail-open한다', async () => { - const fixture = createRateLimitEnv(); - fixture.stubFetch.mockRejectedValueOnce('failed'); + it('본문이 잘못된 성공 응답이나 Error가 아닌 예외도 민감정보 없이 fail-open한다', async () => { + const malformedFixture = createRateLimitEnv(() => new Response('not-json')); + const thrownFixture = createRateLimitEnv(); + thrownFixture.stubFetch.mockRejectedValueOnce('failed 203.0.113.10'); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const request = new Request('https://example.com/api/cgv/timetable', { + headers: { 'CF-Connecting-IP': '203.0.113.10' }, + }); - const result = await consumeDailyRateLimit( - new Request('https://example.com/api/cgv/timetable', { - headers: { 'CF-Connecting-IP': '203.0.113.10' }, - }), - fixture.env, - ); + const malformedResult = await consumeDailyRateLimit(request, malformedFixture.env); + const thrownResult = await consumeDailyRateLimit(request, thrownFixture.env); - expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith('일일 호출 제한 확인 실패', 'failed'); + expect(malformedResult).toBeNull(); + expect(thrownResult).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + const logged = JSON.stringify(consoleSpy.mock.calls); + expect(logged).not.toContain('failed 203.0.113.10'); + expect(logged).not.toContain('203.0.113.10'); }); }); diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 16131dd..5b26557 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -2,24 +2,82 @@ * 저장소 운영 설정 회귀 테스트 */ -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +const setupNodeWorkflows = [ + 'ci.yml', + 'coverage.yml', + 'deploy.yml', + 'external-smoke.yml', + 'npm-publish.yml', + 'sync-worker-secrets.yml', + 'workers-invocations-chart.yml', +]; + function readText(path: string): string { return readFileSync(path, 'utf8'); } describe('repository maintenance configuration', () => { - it('Node 엔진 범위는 현재 LTS 이상을 허용한다', () => { + it('Node 엔진 범위는 Wrangler가 요구하는 버전과 일치한다', () => { const pkg = JSON.parse(readText('package.json')) as { engines?: { node?: string } }; - expect(pkg.engines?.node).toBe('>=20'); + expect(pkg.engines?.node).toBe('>=22'); + }); + + it('.nvmrc는 현재 LTS를 사용한다', () => { + expect(readText('.nvmrc').trim()).toBe('24'); + }); + + it('기여 가이드는 Node.js 24를 개발 버전으로 안내한다', () => { + expect(readText('CONTRIBUTING.md')).toContain('- Node.js: `24.x`'); + }); + + it('기여 가이드는 Node.js 24에 포함된 npm 11을 안내한다', () => { + expect(readText('CONTRIBUTING.md')).toContain('- npm: `11.x`'); + }); + + it('기여 가이드의 Node.js 버전은 .nvmrc와 일치한다', () => { + const contributing = readText('CONTRIBUTING.md'); + const nodeVersion = contributing.match(/^- Node\.js: `(\d+)\.x`/m)?.[1]; + + expect(nodeVersion).toBe(readText('.nvmrc').trim()); + expect(contributing).toContain('(`.nvmrc` 참고)'); + expect(contributing).toContain('\nnvm use\n'); }); - it('npm audit 경고가 난 ws 전이 의존성은 안전 버전으로 고정한다', () => { - const pkg = JSON.parse(readText('package.json')) as { overrides?: Record }; + it('MCP SDK가 아직 선택하지 못하는 Hono Node 서버 보안 버전을 부모 범위로 고정한다', () => { + const pkg = JSON.parse(readText('package.json')) as { + overrides?: Record>; + }; - expect(pkg.overrides?.ws).toBe('8.21.0'); + expect(pkg.overrides?.['@modelcontextprotocol/sdk']).toEqual({ + '@hono/node-server': '2.0.11', + }); + }); + + it('Miniflare가 아직 선택하지 못하는 Sharp 보안 버전을 부모 범위로 고정한다', () => { + const pkg = JSON.parse(readText('package.json')) as { + overrides?: Record>; + }; + + expect(pkg.overrides?.miniflare).toEqual({ sharp: '0.35.3' }); + }); + + it('모든 GitHub Actions workflow에서 setup-node v6을 사용하지 않는다', () => { + const workflows = readdirSync('.github/workflows') + .filter((file) => file.endsWith('.yml')) + .map((file) => readText(`.github/workflows/${file}`)) + .join('\n'); + + expect(workflows).not.toContain('actions/setup-node@v6'); + }); + + it('Node를 설정하는 일곱 workflow는 setup-node v7을 사용한다', () => { + for (const workflow of setupNodeWorkflows) { + expect(readText(`.github/workflows/${workflow}`)).toContain('actions/setup-node@v7'); + } }); it('release 문서는 git 기록을 npm publish보다 먼저 남기도록 안내한다', () => { @@ -28,7 +86,9 @@ describe('repository maintenance configuration', () => { expect(agents).toContain('릴리스 절차'); expect(agents).toContain('npm version'); expect(agents).toContain('git push origin main --follow-tags'); - expect(agents.indexOf('git push origin main --follow-tags')).toBeLessThan(agents.indexOf('npm publish')); + expect(agents.indexOf('git push origin main --follow-tags')).toBeLessThan( + agents.indexOf('npm publish'), + ); }); it('운영 메모는 repo 내부 문서에 포함된다', () => { @@ -46,7 +106,9 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain('workflow_dispatch:'); expect(workflow).toContain("cron: '40 15 * * *'"); - expect(workflow).toContain('group: external-smoke-${{ github.ref }}-${{ matrix.suite }}-${{ matrix.service }}'); + expect(workflow).toContain( + 'group: external-smoke-${{ github.ref }}-${{ matrix.suite }}-${{ matrix.service }}', + ); expect(workflow).toContain('fail-fast: false'); expect(workflow).toContain('max-parallel: 4'); expect(workflow).toContain('service: daiso'); @@ -103,7 +165,9 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain('CLOUDFLARE_GLOBAL_API_KEY'); expect(workflow).toContain('CLOUDFLARE_ZONE_ID'); expect(workflow).toContain('WORKERS_CHART_ROOT_REDIRECT_START'); - expect(workflow).toContain('git add README.md assets/analytics/workers-invocations.json assets/analytics/workers-invocations.png'); + expect(workflow).toContain( + 'git add README.md assets/analytics/workers-invocations.json assets/analytics/workers-invocations.png', + ); expect(workflow).toContain('git pull --rebase --autostash origin main'); expect(workflow).toContain('git push origin HEAD:main'); }); @@ -192,6 +256,99 @@ describe('repository maintenance configuration', () => { expect(readme).toContain('npx daiso'); }); + it('운영 문서는 정확한 429 통계 조회 계약과 범위를 설명한다', () => { + const readme = readText('README.md'); + const serviceReference = readText('docs/service-reference.md'); + const readmeOperations = readme.slice( + readme.indexOf('### 운영 통계'), + readme.indexOf('배포 전 로컬에서 CLI 모드까지 확인', readme.indexOf('### 운영 통계')), + ); + const serviceReferenceOperations = serviceReference.slice( + serviceReference.indexOf('### 호출 제한 운영 통계'), + serviceReference.indexOf('### 제품 검색'), + ); + const curlBlockMatch = readmeOperations.match(/```bash\r?\n([\s\S]*?)\r?\n```/); + if (!curlBlockMatch?.[1]) { + throw new Error('README 운영 통계 절에 `bash` 코드 블록 호출 예시가 필요합니다.'); + } + const jsonBlockMatch = readmeOperations.match(/```json\r?\n([\s\S]*?)\r?\n```/); + if (!jsonBlockMatch?.[1]) { + throw new Error('README 운영 통계 절에 `json` 코드 블록 성공 응답 예시가 필요합니다.'); + } + let statsExample: unknown; + try { + statsExample = JSON.parse(jsonBlockMatch[1]); + } catch (error) { + throw new Error(`README 운영 통계 JSON 예시를 파싱할 수 없습니다: ${String(error)}`); + } + + expect(readmeOperations).toContain('GET /api/rate-limit/stats'); + expect(readmeOperations).toContain('HEALTH_CHECK_SECRET'); + expect(readmeOperations).toContain('Authorization: Bearer $HEALTH_CHECK_SECRET'); + expect(readmeOperations).toContain('x-health-check-key: $HEALTH_CHECK_SECRET'); + expect(readmeOperations).toContain('`from`, `to`, `service`'); + expect(readmeOperations).toContain('`from`과 `to`는 함께 지정하거나 둘 다 생략'); + expect(readmeOperations).toContain('`oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`'); + expect(readmeOperations).toContain('현재 KST 일자를 포함한 최근 7일'); + expect(readmeOperations).toContain('KST 달력 날짜 기준 최대 30일'); + expect(readmeOperations).toContain('일별·서비스별 차단 요청 수와 고유 차단 주체 수'); + expect(readmeOperations).toContain('Worker가 생성한 `DAILY_RATE_LIMIT_EXCEEDED` 결정'); + expect(readmeOperations).toContain('원장 커밋에 성공한 경우만 정확한 집계 범위'); + expect(readmeOperations).toContain('Cloudflare 또는 네트워크 계층의 429'); + expect(readmeOperations).toContain('클라이언트 전송 결과, 연결 종료 결과'); + expect(readmeOperations).toContain('fail-open'); + expect(readmeOperations).toContain('애플리케이션 429를 반환하지 않습니다'); + expect(readmeOperations).toContain('배포 시점부터 수집'); + expect(readmeOperations).toContain('이전 429는 소급 집계하지 않습니다'); + expect(readmeOperations).toContain('원본 호출 주체나 IP를 노출하지 않습니다'); + expect(curlBlockMatch[1]).not.toMatch(/[?&](?:from|to)=\d{4}-\d{2}-\d{2}/); + expect(statsExample).toEqual({ + success: true, + data: { + totals: { blockedRequests: 3, uniqueIdentities: 2 }, + daily: [ + { + day: '2026-07-22', + blockedRequests: 3, + uniqueIdentities: 2, + }, + ], + services: [ + { + day: '2026-07-22', + service: 'cgv', + blockedRequests: 3, + uniqueIdentities: 2, + }, + ], + }, + }); + + expect(serviceReferenceOperations).toContain('GET /api/rate-limit/stats'); + expect(serviceReferenceOperations).toContain('HEALTH_CHECK_SECRET'); + expect(serviceReferenceOperations).toContain('Authorization: Bearer'); + expect(serviceReferenceOperations).toContain('x-health-check-key'); + expect(serviceReferenceOperations).toContain( + '`from`과 `to`는 둘 다 생략하거나 함께 제공해야 합니다', + ); + expect(serviceReferenceOperations).toContain('`oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`'); + expect(serviceReferenceOperations).toContain('현재 KST 일자를 포함한 최근 7일'); + expect(serviceReferenceOperations).toContain('차단 이벤트는 30일 동안 보관합니다'); + expect(serviceReferenceOperations).toContain('KST 달력 날짜 기준 최대 30일'); + expect(serviceReferenceOperations).toContain( + 'Worker가 생성한 `DAILY_RATE_LIMIT_EXCEEDED` 결정', + ); + expect(serviceReferenceOperations).toContain('원장 커밋에 성공한 경우만 정확한 집계 범위'); + expect(serviceReferenceOperations).toContain('Cloudflare 또는 네트워크 계층의 429'); + expect(serviceReferenceOperations).toContain('클라이언트 전송 결과, 연결 종료 결과'); + expect(serviceReferenceOperations).toContain('fail-open'); + expect(serviceReferenceOperations).toContain('애플리케이션 429를 반환하지 않습니다'); + expect(serviceReferenceOperations).toContain('집계만 반환'); + expect(serviceReferenceOperations).toContain('원본 호출 주체나 IP를 노출하지 않습니다'); + expect(serviceReferenceOperations).toContain('배포 시점부터 수집'); + expect(serviceReferenceOperations).toContain('이전 429는 소급 집계하지 않습니다'); + }); + it('daiso CLI 스킬은 에이전트가 CLI와 MCP를 함께 사용할 수 있게 안내한다', () => { const skill = readText('skills/daiso-cli/SKILL.md'); const commandMap = readText('skills/daiso-cli/references/cli-command-map.md');