daiso 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +75 -5
  2. package/dist/api/actionsProxy.js +3 -0
  3. package/dist/api/cgvHandlers.js +15 -3
  4. package/dist/api/configStatus.js +1 -1
  5. package/dist/api/dtryxHandlers.js +113 -0
  6. package/dist/api/emart24Handlers.js +6 -2
  7. package/dist/api/gs25Handlers.js +22 -19
  8. package/dist/api/handlers.js +3 -0
  9. package/dist/api/healthCheckDefinitions.js +46 -16
  10. package/dist/api/healthCheckShape.js +24 -8
  11. package/dist/api/healthChecks.js +39 -20
  12. package/dist/api/operationalAuth.js +32 -0
  13. package/dist/api/routes/dtryxRoutes.js +22 -0
  14. package/dist/api/routes/healthRoutes.js +19 -11
  15. package/dist/api/routes/rateLimitStatsRoutes.js +274 -0
  16. package/dist/api/routes/registerServiceRoutes.js +37 -0
  17. package/dist/api/sevenelevenHandlers.js +7 -3
  18. package/dist/cli/commandOptions.js +24 -0
  19. package/dist/cli/commands/cinema.js +27 -0
  20. package/dist/cli.js +7 -1
  21. package/dist/cliHelp.js +3 -337
  22. package/dist/cliHelpDefinitions.js +369 -0
  23. package/dist/cliInteractive.js +8 -5
  24. package/dist/core/errors.js +17 -2
  25. package/dist/core/registry.js +13 -15
  26. package/dist/durableObjects/dailyRateLimiter.js +185 -0
  27. package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
  28. package/dist/index.js +16 -30
  29. package/dist/middleware/dailyRateLimit.js +176 -0
  30. package/dist/pages/openapiFullSpec.js +2 -0
  31. package/dist/pages/openapiSpecActionParameters.js +429 -0
  32. package/dist/pages/openapiSpecActions.js +1 -391
  33. package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
  34. package/dist/pages/openapiSpecPathsDtryx.js +148 -0
  35. package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
  36. package/dist/pages/promptRetailCinemaText.js +9 -1
  37. package/dist/services/cgv/errors.js +12 -0
  38. package/dist/services/cgv/transport.js +19 -2
  39. package/dist/services/cu/client.js +42 -12
  40. package/dist/services/cu/index.js +13 -3
  41. package/dist/services/cu/tools/checkInventory.js +65 -45
  42. package/dist/services/cu/tools/findNearbyStores.js +4 -3
  43. package/dist/services/cu/upstreamError.js +15 -0
  44. package/dist/services/dtryx/api.js +25 -0
  45. package/dist/services/dtryx/client.js +133 -0
  46. package/dist/services/dtryx/index.js +24 -0
  47. package/dist/services/dtryx/location.js +216 -0
  48. package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
  49. package/dist/services/dtryx/tools/listCinemas.js +46 -0
  50. package/dist/services/dtryx/tools/listNowShowing.js +77 -0
  51. package/dist/services/dtryx/types.js +4 -0
  52. package/dist/services/dtryx/validation.js +21 -0
  53. package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
  54. package/dist/services/gs25/api.js +2 -2
  55. package/dist/services/gs25/client.js +16 -123
  56. package/dist/services/gs25/errors.js +12 -0
  57. package/dist/services/gs25/index.js +2 -2
  58. package/dist/services/gs25/productSearch.js +1 -0
  59. package/dist/services/gs25/storeStockTransport.js +55 -0
  60. package/dist/services/gs25/tools/checkInventory.js +7 -2
  61. package/dist/services/gs25/tools/findNearbyStores.js +20 -5
  62. package/dist/services/lottecinema/client.js +4 -2
  63. package/dist/services/lottemart/config.js +1 -1
  64. package/dist/services/lottemart/session.js +29 -160
  65. package/dist/services/lottemart/socketTransport.js +149 -0
  66. package/dist/services/oliveyoung/client.js +1 -0
  67. package/dist/services/seveneleven/client.js +18 -8
  68. package/dist/services/seveneleven/index.js +11 -7
  69. package/dist/services/seveneleven/inventory.js +5 -2
  70. package/dist/services/seveneleven/productKeyword.js +3 -2
  71. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  72. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  73. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  74. package/dist/services/seveneleven/tools/searchProducts.js +4 -3
  75. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  76. package/dist/utils/format.js +10 -3
  77. package/dist/utils/http.js +38 -10
  78. package/dist/utils/zyte.js +13 -2
  79. package/dist/utils/zyteJsonFallback.js +67 -0
  80. package/openapi.json +51 -3
  81. package/openapi.yaml +36 -3
  82. package/package.json +28 -27
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 차단된 일일 호출 제한 이벤트를 정확히 집계하는 SQLite 저장소
3
+ */
4
+ export const RATE_LIMIT_METRICS_RETENTION_DAYS = 30;
5
+ export const RATE_LIMIT_METRICS_LEDGER_NAME = '__blocked-ledger-v1__';
6
+ export const RATE_LIMIT_SERVICES = ['oliveyoung', 'cgv', 'cu', 'gs25', 'lottemart'];
7
+ export class StaleRateLimitEventDayError extends Error {
8
+ constructor() {
9
+ super('차단 이벤트 날짜가 만료되었습니다.');
10
+ this.name = 'StaleRateLimitEventDayError';
11
+ }
12
+ }
13
+ const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
14
+ const ALARM_DELAY_MS = 5 * 60 * 1000;
15
+ // 요청 경로의 SQLite 쓰기 증폭을 제한하고 나머지는 일일 alarm에 맡깁니다.
16
+ const OPPORTUNISTIC_CLEANUP_LIMIT = 100;
17
+ function toKstDay(nowMs) {
18
+ return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10);
19
+ }
20
+ function retainedCutoffDay(asOfDay) {
21
+ const asOf = new Date(`${asOfDay}T00:00:00.000Z`);
22
+ return new Date(Date.UTC(asOf.getUTCFullYear(), asOf.getUTCMonth(), asOf.getUTCDate() - (RATE_LIMIT_METRICS_RETENTION_DAYS - 1)))
23
+ .toISOString()
24
+ .slice(0, 10);
25
+ }
26
+ function retentionCutoffDay(nowMs) {
27
+ return retainedCutoffDay(toKstDay(nowMs));
28
+ }
29
+ function nextAlarmTime(nowMs) {
30
+ const shifted = new Date(nowMs + KST_OFFSET_MS);
31
+ return (Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) -
32
+ KST_OFFSET_MS +
33
+ ALARM_DELAY_MS);
34
+ }
35
+ function isRateLimitService(service) {
36
+ return RATE_LIMIT_SERVICES.includes(service);
37
+ }
38
+ function toAggregate(row) {
39
+ return {
40
+ blockedRequests: row.blocked_requests,
41
+ uniqueIdentities: row.unique_identities,
42
+ };
43
+ }
44
+ export class RateLimitMetricsStore {
45
+ state;
46
+ sql;
47
+ constructor(state) {
48
+ this.state = state;
49
+ this.sql = state.storage.sql;
50
+ this.initializeSchema();
51
+ }
52
+ async record(event) {
53
+ if (event.day !== toKstDay(event.occurredAt)) {
54
+ throw new TypeError('이벤트 날짜가 KST 발생 날짜와 일치하지 않습니다.');
55
+ }
56
+ await this.ensureAlarm();
57
+ const commitNowMs = Date.now();
58
+ if (event.day !== toKstDay(commitNowMs)) {
59
+ throw new StaleRateLimitEventDayError();
60
+ }
61
+ this.cleanupExpiredBatch(commitNowMs);
62
+ this.sql.exec(`INSERT OR IGNORE INTO blocked_events
63
+ (event_id, occurred_at, day, service, identity_id)
64
+ VALUES (?, ?, ?, ?, ?)`, event.eventId, event.occurredAt, event.day, event.service, event.identityId);
65
+ }
66
+ async query(input) {
67
+ if (input.service !== undefined && !isRateLimitService(input.service)) {
68
+ throw new TypeError('지원하지 않는 서비스입니다.');
69
+ }
70
+ const cutoffDay = retainedCutoffDay(input.asOfDay);
71
+ const serviceClause = input.service === undefined ? '' : ' AND service = ?';
72
+ const bindings = input.service === undefined
73
+ ? [input.from, input.to, cutoffDay]
74
+ : [input.from, input.to, cutoffDay, input.service];
75
+ const whereClause = `day >= ? AND day <= ? AND day >= ?${serviceClause}`;
76
+ const totals = this.sql
77
+ .exec(`SELECT COUNT(*) AS blocked_requests,
78
+ COUNT(DISTINCT identity_id) AS unique_identities
79
+ FROM blocked_events
80
+ WHERE ${whereClause}`, ...bindings)
81
+ .one();
82
+ const daily = this.sql
83
+ .exec(`SELECT day,
84
+ COUNT(*) AS blocked_requests,
85
+ COUNT(DISTINCT identity_id) AS unique_identities
86
+ FROM blocked_events
87
+ WHERE ${whereClause}
88
+ GROUP BY day
89
+ ORDER BY day ASC`, ...bindings)
90
+ .toArray();
91
+ const services = this.sql
92
+ .exec(`SELECT day,
93
+ service,
94
+ COUNT(*) AS blocked_requests,
95
+ COUNT(DISTINCT identity_id) AS unique_identities
96
+ FROM blocked_events
97
+ WHERE ${whereClause}
98
+ GROUP BY day, service
99
+ ORDER BY day ASC, service ASC`, ...bindings)
100
+ .toArray();
101
+ const stats = {
102
+ totals: toAggregate(totals),
103
+ daily: daily.map((row) => ({ day: row.day, ...toAggregate(row) })),
104
+ services: services.map((row) => ({
105
+ day: row.day,
106
+ service: row.service,
107
+ ...toAggregate(row),
108
+ })),
109
+ };
110
+ this.cleanupExpiredBatch(Date.now());
111
+ return stats;
112
+ }
113
+ async cleanup(nowMs = Date.now()) {
114
+ this.sql.exec('DELETE FROM blocked_events WHERE day < ?', retentionCutoffDay(nowMs));
115
+ }
116
+ async ensureAlarm(nowMs = Date.now()) {
117
+ if ((await this.state.storage.getAlarm()) !== null) {
118
+ return;
119
+ }
120
+ await this.state.storage.setAlarm(nextAlarmTime(nowMs));
121
+ }
122
+ initializeSchema() {
123
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS blocked_events (
124
+ event_id TEXT PRIMARY KEY,
125
+ occurred_at INTEGER NOT NULL,
126
+ day TEXT NOT NULL,
127
+ service TEXT NOT NULL,
128
+ identity_id TEXT NOT NULL
129
+ )`);
130
+ this.sql.exec('CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service ON blocked_events(day, service)');
131
+ this.sql.exec('CREATE INDEX IF NOT EXISTS idx_blocked_events_day_identity ON blocked_events(day, identity_id)');
132
+ this.sql.exec('CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service_identity ON blocked_events(day, service, identity_id)');
133
+ }
134
+ cleanupExpiredBatch(nowMs) {
135
+ this.sql.exec(`DELETE FROM blocked_events
136
+ WHERE event_id IN (
137
+ SELECT event_id
138
+ FROM blocked_events
139
+ WHERE day < ?
140
+ ORDER BY day ASC, event_id ASC
141
+ LIMIT ?
142
+ )`, retentionCutoffDay(nowMs), OPPORTUNISTIC_CLEANUP_LIMIT);
143
+ }
144
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
11
11
  import { ServiceRegistry } from './core/registry.js';
12
12
  import { createDaisoService } from './services/daiso/index.js';
13
13
  import { createOliveyoungService } from './services/oliveyoung/index.js';
14
+ import { createDtryxService } from './services/dtryx/index.js';
14
15
  import { createMegaboxService } from './services/megabox/index.js';
15
16
  import { createCgvService } from './services/cgv/index.js';
16
17
  import { createLotteCinemaService } from './services/lottecinema/index.js';
@@ -27,22 +28,12 @@ import { createPromptResponse } from './pages/prompt.js';
27
28
  import { createFullOpenApiJsonResponse, createFullOpenApiYamlResponse, createOpenApiJsonResponse, createOpenApiYamlResponse, } from './pages/openapi.js';
28
29
  import { createPrivacyResponse } from './pages/privacy.js';
29
30
  import { buildActionQueryTargetUrl } from './api/actionsProxy.js';
30
- import { registerDaisoRoutes } from './api/routes/daisoRoutes.js';
31
- import { registerOliveyoungRoutes } from './api/routes/oliveyoungRoutes.js';
32
- import { registerMegaboxRoutes } from './api/routes/megaboxRoutes.js';
33
- import { registerCgvRoutes } from './api/routes/cgvRoutes.js';
34
- import { registerLotteCinemaRoutes } from './api/routes/lottecinemaRoutes.js';
35
- import { registerLotteMartRoutes } from './api/routes/lottemartRoutes.js';
36
- import { registerCuRoutes } from './api/routes/cuRoutes.js';
37
- import { registerEmart24Routes } from './api/routes/emart24Routes.js';
38
- import { registerGs25Routes } from './api/routes/gs25Routes.js';
39
- import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
40
- import { registerPlacesRoutes } from './api/routes/placesRoutes.js';
41
- import { registerOpinetRoutes } from './api/routes/opinetRoutes.js';
42
- import { registerCompareRoutes } from './api/routes/compareRoutes.js';
43
- import { registerFeedbackRoutes } from './api/routes/feedbackRoutes.js';
44
31
  import { registerHealthRoutes } from './api/routes/healthRoutes.js';
32
+ import { registerServiceRoutes } from './api/routes/registerServiceRoutes.js';
33
+ import { registerRateLimitStatsRoutes } from './api/routes/rateLimitStatsRoutes.js';
45
34
  import { buildConfigStatus } from './api/configStatus.js';
35
+ import { createDailyRateLimitMiddleware } from './middleware/dailyRateLimit.js';
36
+ export { DailyRateLimiter } from './durableObjects/dailyRateLimiter.js';
46
37
  // 서버 메타데이터
47
38
  const SERVER_NAME = 'multi-service-mcp';
48
39
  const SERVER_VERSION = '1.0.0';
@@ -59,8 +50,9 @@ const createRegistry = (bindings) => {
59
50
  () => createGs25Service({
60
51
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
61
52
  zyteApiKey: bindings?.ZYTE_API_KEY,
53
+ apiKey: bindings?.GS25_API_KEY,
62
54
  }),
63
- createSevenElevenService,
55
+ () => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }),
64
56
  createCompareService,
65
57
  () => createFeedbackService({
66
58
  supabaseUrl: bindings?.SUPABASE_URL,
@@ -74,13 +66,17 @@ const createRegistry = (bindings) => {
74
66
  apiKey: bindings?.OPINET_API_KEY,
75
67
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
76
68
  }),
77
- createCuService,
69
+ () => createCuService({
70
+ zyteApiKey: bindings?.ZYTE_API_KEY,
71
+ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
72
+ }),
78
73
  createEmart24Service,
79
74
  () => createLotteMartService({
80
75
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
81
76
  zyteApiKey: bindings?.ZYTE_API_KEY,
82
77
  }),
83
78
  createMegaboxService,
79
+ createDtryxService,
84
80
  () => createLotteCinemaService({
85
81
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
86
82
  }),
@@ -259,6 +255,8 @@ app.use('*', cors({
259
255
  ],
260
256
  exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'],
261
257
  }));
258
+ // Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
259
+ app.use('/api/*', createDailyRateLimitMiddleware());
262
260
  // 기본 정보 엔드포인트 (GET 요청만)
263
261
  app.get('/', (c) => {
264
262
  return c.body(ROOT_INFO_JSON, 200, ROOT_INFO_HEADERS);
@@ -268,6 +266,8 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest);
268
266
  // 헬스 체크 엔드포인트
269
267
  app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
270
268
  registerHealthRoutes(app);
269
+ registerRateLimitStatsRoutes(app);
270
+ registerServiceRoutes(app);
271
271
  // 프롬프트 페이지 (MCP 미지원 에이전트용)
272
272
  app.get('/prompt', (c) => {
273
273
  const baseUrl = new URL(c.req.url).origin;
@@ -296,20 +296,6 @@ app.get('/privacy', (c) => {
296
296
  return createPrivacyResponse(baseUrl);
297
297
  });
298
298
  // GET API 엔드포인트 (MCP 미지원 에이전트용)
299
- registerDaisoRoutes(app);
300
- registerGs25Routes(app);
301
- registerSevenElevenRoutes(app);
302
- registerCompareRoutes(app);
303
- registerFeedbackRoutes(app);
304
- registerPlacesRoutes(app);
305
- registerOpinetRoutes(app);
306
- registerCuRoutes(app);
307
- registerEmart24Routes(app);
308
- registerLotteMartRoutes(app);
309
- registerOliveyoungRoutes(app);
310
- registerMegaboxRoutes(app);
311
- registerLotteCinemaRoutes(app);
312
- registerCgvRoutes(app);
313
299
  app.get('/api/actions/query', async (c) => {
314
300
  try {
315
301
  const targetUrl = buildActionQueryTargetUrl(c.req.url);
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Zyte 연동 공개 GET API의 클라이언트별 일일 호출 제한 미들웨어
3
+ */
4
+ import { errorResponse } from '../api/response.js';
5
+ import { DAILY_RATE_LIMIT, nextKstMidnightEpochSeconds, toKstDay, } from '../durableObjects/dailyRateLimiter.js';
6
+ import { RATE_LIMIT_METRICS_LEDGER_NAME, } from '../durableObjects/rateLimitMetricsStore.js';
7
+ const PROTECTED_SERVICES = [
8
+ { prefix: '/api/oliveyoung/', service: 'oliveyoung' },
9
+ { prefix: '/api/cgv/', service: 'cgv' },
10
+ { prefix: '/api/cu/', service: 'cu' },
11
+ { prefix: '/api/gs25/', service: 'gs25' },
12
+ { prefix: '/api/lottemart/', service: 'lottemart' },
13
+ ];
14
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
15
+ // Cloudflare가 교차 존 Worker subrequest에 사용하는 공통 client IP
16
+ const CROSS_ZONE_WORKER_CLIENT_IP = '2a06:98c0:3600::103';
17
+ function resolveRateLimitService(request) {
18
+ if (request.method !== 'GET') {
19
+ return null;
20
+ }
21
+ const pathname = new URL(request.url).pathname;
22
+ return PROTECTED_SERVICES.find(({ prefix }) => pathname.startsWith(prefix))?.service ?? null;
23
+ }
24
+ export function isDailyRateLimitedRequest(request) {
25
+ return resolveRateLimitService(request) !== null;
26
+ }
27
+ function isHealthCheckBypass(request, env) {
28
+ const secret = env?.HEALTH_CHECK_SECRET?.trim();
29
+ if (!secret) {
30
+ return false;
31
+ }
32
+ return request.headers.get('x-health-check-key')?.trim() === secret;
33
+ }
34
+ export async function hashRateLimitIdentity(ip) {
35
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
36
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
37
+ }
38
+ function resolveRateLimitIdentity(request) {
39
+ const ip = request.headers.get('CF-Connecting-IP')?.trim();
40
+ if (!ip || ip !== CROSS_ZONE_WORKER_CLIENT_IP) {
41
+ return ip || null;
42
+ }
43
+ const workerZone = request.headers.get('CF-Worker')?.trim().toLowerCase();
44
+ return workerZone ? `worker-zone:${workerZone}` : ip;
45
+ }
46
+ function isRecord(value) {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+ function isExactDate(value) {
50
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
51
+ return false;
52
+ }
53
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
54
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
55
+ }
56
+ function isSafeIntegerInRange(value, minimum, maximum) {
57
+ return (typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum);
58
+ }
59
+ function isDailyRateLimitResult(value) {
60
+ if (!isRecord(value) || typeof value.allowed !== 'boolean') {
61
+ return false;
62
+ }
63
+ const { allowed, count, remaining, resetAt, day } = value;
64
+ if (!isSafeIntegerInRange(count, 0, DAILY_RATE_LIMIT) ||
65
+ !isSafeIntegerInRange(remaining, 0, DAILY_RATE_LIMIT) ||
66
+ !isSafeIntegerInRange(resetAt, 1, Number.MAX_SAFE_INTEGER) ||
67
+ !isExactDate(day)) {
68
+ return false;
69
+ }
70
+ const countersMatch = count + remaining === DAILY_RATE_LIMIT;
71
+ return allowed ? countersMatch && count > 0 : count === DAILY_RATE_LIMIT && remaining === 0;
72
+ }
73
+ async function consumeDailyRateLimitDecision(request, env) {
74
+ const service = resolveRateLimitService(request);
75
+ if (!service || isHealthCheckBypass(request, env)) {
76
+ return null;
77
+ }
78
+ const identity = resolveRateLimitIdentity(request);
79
+ const namespace = env?.DAILY_RATE_LIMITER;
80
+ if (!identity || !namespace) {
81
+ return null;
82
+ }
83
+ try {
84
+ const identityId = namespace.idFromName(await hashRateLimitIdentity(identity));
85
+ const stub = namespace.get(identityId);
86
+ const response = await stub.fetch('https://daily-rate-limit/consume', { method: 'POST' });
87
+ if (!response.ok) {
88
+ console.error('일일 호출 제한 확인 실패', `Durable Object HTTP ${response.status}`);
89
+ return null;
90
+ }
91
+ const result = await response.json();
92
+ if (!isDailyRateLimitResult(result)) {
93
+ console.error('일일 호출 제한 응답 검증 실패');
94
+ return null;
95
+ }
96
+ const nowMs = Date.now();
97
+ if (result.day !== toKstDay(nowMs)) {
98
+ console.error('일일 호출 제한 날짜 전환 감지');
99
+ return null;
100
+ }
101
+ if (result.resetAt !== nextKstMidnightEpochSeconds(nowMs)) {
102
+ console.error('일일 호출 제한 초기화 시각 검증 실패');
103
+ return null;
104
+ }
105
+ return {
106
+ result,
107
+ service,
108
+ identityId,
109
+ namespace,
110
+ nowMs,
111
+ };
112
+ }
113
+ catch {
114
+ console.error('일일 호출 제한 확인 실패');
115
+ return null;
116
+ }
117
+ }
118
+ export async function consumeDailyRateLimit(request, env) {
119
+ return (await consumeDailyRateLimitDecision(request, env))?.result ?? null;
120
+ }
121
+ async function recordBlockedRateLimitEvent(decision) {
122
+ const event = {
123
+ eventId: crypto.randomUUID(),
124
+ occurredAt: decision.nowMs,
125
+ day: decision.result.day,
126
+ service: decision.service,
127
+ identityId: decision.identityId.toString(),
128
+ };
129
+ try {
130
+ const ledgerId = decision.namespace.idFromName(RATE_LIMIT_METRICS_LEDGER_NAME);
131
+ const ledger = decision.namespace.get(ledgerId);
132
+ const response = await ledger.fetch('https://daily-rate-limit/blocked-events', {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify(event),
136
+ });
137
+ if (!(response instanceof Response)) {
138
+ console.error('일일 호출 제한 원장 기록 실패', '응답 형식 오류');
139
+ return false;
140
+ }
141
+ if (response.status !== 204) {
142
+ console.error('일일 호출 제한 원장 기록 실패', `Durable Object HTTP ${response.status}`);
143
+ return false;
144
+ }
145
+ return true;
146
+ }
147
+ catch {
148
+ console.error('일일 호출 제한 원장 기록 실패');
149
+ return false;
150
+ }
151
+ }
152
+ export function setRateLimitHeaders(headers, result) {
153
+ headers.set('X-RateLimit-Limit', String(DAILY_RATE_LIMIT));
154
+ headers.set('X-RateLimit-Remaining', String(result.remaining));
155
+ headers.set('X-RateLimit-Reset', String(result.resetAt));
156
+ }
157
+ export function createDailyRateLimitMiddleware() {
158
+ return async (c, next) => {
159
+ const decision = await consumeDailyRateLimitDecision(c.req.raw, c.env);
160
+ if (!decision) {
161
+ return next();
162
+ }
163
+ const { result } = decision;
164
+ if (!result.allowed) {
165
+ if (!(await recordBlockedRateLimitEvent(decision))) {
166
+ return next();
167
+ }
168
+ const response = errorResponse(c, 'DAILY_RATE_LIMIT_EXCEEDED', '이 IP의 일일 호출 한도 3,000회를 초과했습니다.', 429);
169
+ setRateLimitHeaders(response.headers, result);
170
+ response.headers.set('Retry-After', String(Math.max(1, result.resetAt - Math.floor(decision.nowMs / 1000))));
171
+ return response;
172
+ }
173
+ await next();
174
+ setRateLimitHeaders(c.res.headers, result);
175
+ };
176
+ }
@@ -14,6 +14,7 @@ import { OPENAPI_PATHS_PLACES } from './openapiSpecPathsPlaces.js';
14
14
  import { OPENAPI_PATHS_OPINET } from './openapiSpecPathsOpinet.js';
15
15
  import { OPENAPI_PATHS_COMPARE } from './openapiSpecPathsCompare.js';
16
16
  import { OPENAPI_PATHS_FEEDBACK } from './openapiSpecPathsFeedback.js';
17
+ import { OPENAPI_PATHS_DTRYX } from './openapiSpecPathsDtryx.js';
17
18
  import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js';
18
19
  export function generateFullOpenApiSpec(baseUrl) {
19
20
  return {
@@ -63,6 +64,7 @@ export function generateFullOpenApiSpec(baseUrl) {
63
64
  ...OPENAPI_PATHS_MEGABOX,
64
65
  ...OPENAPI_PATHS_LOTTECINEMA,
65
66
  ...OPENAPI_PATHS_CGV,
67
+ ...OPENAPI_PATHS_DTRYX,
66
68
  },
67
69
  components: OPENAPI_COMPONENTS,
68
70
  };