daiso 1.0.9 → 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 (86) hide show
  1. package/README.md +88 -9
  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 +56 -8
  10. package/dist/api/healthCheckShape.js +24 -8
  11. package/dist/api/healthChecks.js +48 -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/commands/convenience.js +2 -2
  21. package/dist/cli.js +7 -1
  22. package/dist/cliHelp.js +3 -337
  23. package/dist/cliHelpDefinitions.js +369 -0
  24. package/dist/cliInteractive.js +8 -5
  25. package/dist/core/errors.js +17 -2
  26. package/dist/core/registry.js +13 -15
  27. package/dist/durableObjects/dailyRateLimiter.js +185 -0
  28. package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
  29. package/dist/index.js +16 -30
  30. package/dist/middleware/dailyRateLimit.js +176 -0
  31. package/dist/pages/openapiFullSpec.js +2 -0
  32. package/dist/pages/openapiSpecActionParameters.js +429 -0
  33. package/dist/pages/openapiSpecActions.js +1 -391
  34. package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
  35. package/dist/pages/openapiSpecPathsDtryx.js +148 -0
  36. package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
  37. package/dist/pages/promptRetailCinemaText.js +9 -1
  38. package/dist/services/cgv/client.js +7 -4
  39. package/dist/services/cgv/errors.js +12 -0
  40. package/dist/services/cgv/transport.js +19 -2
  41. package/dist/services/cu/client.js +42 -12
  42. package/dist/services/cu/index.js +13 -3
  43. package/dist/services/cu/tools/checkInventory.js +65 -45
  44. package/dist/services/cu/tools/findNearbyStores.js +4 -3
  45. package/dist/services/cu/upstreamError.js +15 -0
  46. package/dist/services/dtryx/api.js +25 -0
  47. package/dist/services/dtryx/client.js +133 -0
  48. package/dist/services/dtryx/index.js +24 -0
  49. package/dist/services/dtryx/location.js +216 -0
  50. package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
  51. package/dist/services/dtryx/tools/listCinemas.js +46 -0
  52. package/dist/services/dtryx/tools/listNowShowing.js +77 -0
  53. package/dist/services/dtryx/types.js +4 -0
  54. package/dist/services/dtryx/validation.js +21 -0
  55. package/dist/services/emart24/tools/searchProducts.js +37 -19
  56. package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
  57. package/dist/services/gs25/api.js +2 -2
  58. package/dist/services/gs25/client.js +16 -123
  59. package/dist/services/gs25/errors.js +12 -0
  60. package/dist/services/gs25/index.js +2 -2
  61. package/dist/services/gs25/productSearch.js +1 -0
  62. package/dist/services/gs25/storeStockTransport.js +55 -0
  63. package/dist/services/gs25/tools/checkInventory.js +7 -2
  64. package/dist/services/gs25/tools/findNearbyStores.js +20 -5
  65. package/dist/services/lottecinema/client.js +4 -2
  66. package/dist/services/lottemart/config.js +1 -1
  67. package/dist/services/lottemart/session.js +29 -160
  68. package/dist/services/lottemart/socketTransport.js +149 -0
  69. package/dist/services/oliveyoung/client.js +1 -0
  70. package/dist/services/places/client.js +29 -8
  71. package/dist/services/seveneleven/client.js +18 -8
  72. package/dist/services/seveneleven/index.js +11 -7
  73. package/dist/services/seveneleven/inventory.js +5 -2
  74. package/dist/services/seveneleven/productKeyword.js +3 -2
  75. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  76. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  77. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  78. package/dist/services/seveneleven/tools/searchProducts.js +40 -20
  79. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  80. package/dist/utils/format.js +10 -3
  81. package/dist/utils/http.js +38 -10
  82. package/dist/utils/zyte.js +13 -2
  83. package/dist/utils/zyteJsonFallback.js +67 -0
  84. package/openapi.json +51 -3
  85. package/openapi.yaml +36 -3
  86. package/package.json +28 -24
@@ -17,7 +17,7 @@ export function toCount(data) {
17
17
  }
18
18
  if (record.inventory && typeof record.inventory === 'object') {
19
19
  const inventory = record.inventory;
20
- for (const key of ['products', 'items']) {
20
+ for (const key of ['products', 'items', 'stores']) {
21
21
  const value = inventory[key];
22
22
  if (Array.isArray(value)) {
23
23
  return value.length;
@@ -37,7 +37,15 @@ export function toFirstName(data) {
37
37
  continue;
38
38
  }
39
39
  const item = value[0];
40
- for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName', 'theaterName', 'movieName']) {
40
+ for (const nameKey of [
41
+ 'productName',
42
+ 'itemName',
43
+ 'goodsName',
44
+ 'name',
45
+ 'storeName',
46
+ 'theaterName',
47
+ 'movieName',
48
+ ]) {
41
49
  if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
42
50
  return item[nameKey].trim();
43
51
  }
@@ -45,13 +53,13 @@ export function toFirstName(data) {
45
53
  }
46
54
  if (record.inventory && typeof record.inventory === 'object') {
47
55
  const inventory = record.inventory;
48
- for (const key of ['products', 'items']) {
56
+ for (const key of ['products', 'items', 'stores']) {
49
57
  const value = inventory[key];
50
58
  if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
51
59
  continue;
52
60
  }
53
61
  const item = value[0];
54
- for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
62
+ for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName']) {
55
63
  if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
56
64
  return item[nameKey].trim();
57
65
  }
@@ -65,12 +73,18 @@ function getCollectionItems(data, collectionKey) {
65
73
  return [];
66
74
  }
67
75
  const record = data;
68
- if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') {
76
+ if (collectionKey === 'inventoryProducts' ||
77
+ collectionKey === 'inventoryItems' ||
78
+ collectionKey === 'inventoryStores') {
69
79
  const inventory = record.inventory;
70
80
  if (!inventory || typeof inventory !== 'object') {
71
81
  return [];
72
82
  }
73
- const key = collectionKey === 'inventoryProducts' ? 'products' : 'items';
83
+ const key = collectionKey === 'inventoryProducts'
84
+ ? 'products'
85
+ : collectionKey === 'inventoryStores'
86
+ ? 'stores'
87
+ : 'items';
74
88
  const value = inventory[key];
75
89
  return Array.isArray(value) ? value : [];
76
90
  }
@@ -92,7 +106,7 @@ export function hasRequiredRepresentativeFields(data, collectionKey, requiredFie
92
106
  }
93
107
  const items = getCollectionItems(data, collectionKey);
94
108
  if (items.length === 0) {
95
- return true;
109
+ return false;
96
110
  }
97
111
  const first = items[0];
98
112
  if (!first || typeof first !== 'object' || Array.isArray(first)) {
@@ -101,6 +115,8 @@ export function hasRequiredRepresentativeFields(data, collectionKey, requiredFie
101
115
  const record = first;
102
116
  return requiredFields.some((field) => {
103
117
  const value = record[field];
104
- return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null;
118
+ return typeof value === 'string'
119
+ ? value.trim().length > 0
120
+ : value !== undefined && value !== null;
105
121
  });
106
122
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 개별 서비스 헬스 체크 실행기
3
3
  */
4
- import { GS25_CLOUDFRONT_403_PATTERNS, HEALTH_CHECKS } from './healthCheckDefinitions.js';
4
+ import { EMART24_UPSTREAM_403_PATTERNS, GS25_CLOUDFRONT_403_PATTERNS, HEALTH_CHECKS, SEVENELEVEN_UPSTREAM_403_PATTERNS, } from './healthCheckDefinitions.js';
5
5
  import { hasRequiredRepresentativeFields, toCount, toFirstName } from './healthCheckShape.js';
6
6
  const HEALTH_CHECK_CACHE_TTL_MS = 300_000;
7
7
  const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 7000;
@@ -65,25 +65,25 @@ function aggregateStatus(checks) {
65
65
  if (checks.some((check) => check.status === 'degraded')) {
66
66
  return 'degraded';
67
67
  }
68
+ if (checks.every((check) => check.status === 'skipped')) {
69
+ return 'skipped';
70
+ }
68
71
  return 'ok';
69
72
  }
70
73
  function shouldDegradeFailedResponse(check, message) {
71
74
  return check.degradedFailurePatterns?.some((pattern) => message.includes(pattern)) ?? false;
72
75
  }
73
76
  function shouldDegradeCliContractPath(path, message) {
74
- return path.startsWith('/api/gs25/') && GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
75
- }
76
- function resolveCheckTimeoutMs(check, timeoutMs) {
77
- if (check.timeoutMs === undefined) {
78
- return timeoutMs;
77
+ if (path.startsWith('/api/gs25/')) {
78
+ return GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
79
79
  }
80
- return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
81
- }
82
- function resolveCliContractTimeoutMs(path, timeoutMs) {
83
- if (path.startsWith('/api/oliveyoung/')) {
84
- return Math.min(timeoutMs, 5000);
80
+ if (path.startsWith('/api/emart24/')) {
81
+ return EMART24_UPSTREAM_403_PATTERNS.some((pattern) => message.includes(pattern));
82
+ }
83
+ if (path.startsWith('/api/seveneleven/')) {
84
+ return SEVENELEVEN_UPSTREAM_403_PATTERNS.some((pattern) => message.includes(pattern));
85
85
  }
86
- return timeoutMs;
86
+ return false;
87
87
  }
88
88
  function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
89
89
  const url = new URL(check.path, baseUrl);
@@ -122,7 +122,7 @@ async function runCliContractCheck(check, params) {
122
122
  const cacheBustValue = params.cacheBust ? startedAt : undefined;
123
123
  const degradedMessages = [];
124
124
  for (const path of CLI_CONTRACT_PATHS) {
125
- const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
125
+ const checkTimeoutMs = params.timeoutMs;
126
126
  const syntheticCheck = { ...check, path };
127
127
  try {
128
128
  const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
@@ -172,7 +172,7 @@ async function runSingleCheck(check, params) {
172
172
  if (check.kind === 'cli-contract') {
173
173
  return runCliContractCheck(check, params);
174
174
  }
175
- const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs);
175
+ const timeoutMs = params.timeoutMs;
176
176
  const startedAt = params.now();
177
177
  const cacheBustValue = params.cacheBust ? startedAt : undefined;
178
178
  try {
@@ -193,19 +193,41 @@ async function runSingleCheck(check, params) {
193
193
  message,
194
194
  };
195
195
  }
196
+ const inventory = body.data?.inventory;
197
+ if (check.id === 'cu.inventory' && inventory?.available === false) {
198
+ const reason = inventory.unavailableReason;
199
+ return {
200
+ id: check.id,
201
+ service: check.service,
202
+ target: check.target,
203
+ status: 'degraded',
204
+ durationMs,
205
+ httpStatus: response.status,
206
+ message: typeof reason === 'string' && reason.trim() ? reason.trim() : 'inventory unavailable',
207
+ };
208
+ }
196
209
  const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
197
210
  const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
198
211
  const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
199
212
  const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
200
- const status = count === 0 || !shapeOk || slow ? 'degraded' : 'ok';
213
+ const optionalEmpty = check.allowEmpty === true && count === 0;
214
+ const status = slow
215
+ ? 'degraded'
216
+ : optionalEmpty
217
+ ? 'skipped'
218
+ : count === null || count === 0 || !shapeOk
219
+ ? 'degraded'
220
+ : 'ok';
201
221
  const first = params.includeSamples ? toFirstName(body.data) : undefined;
202
222
  const message = slow
203
223
  ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
204
- : !shapeOk && count !== 0
205
- ? `response missing required fields: ${check.requiredFields.join(', ')}`
224
+ : optionalEmpty
225
+ ? 'optional data unavailable'
206
226
  : count === null
207
- ? 'response ok'
208
- : `${count} item(s) returned`;
227
+ ? 'response count unavailable'
228
+ : !shapeOk && count !== 0
229
+ ? `response missing required fields: ${check.requiredFields.join(', ')}`
230
+ : `${count} item(s) returned`;
209
231
  return {
210
232
  id: check.id,
211
233
  service: check.service,
@@ -236,7 +258,13 @@ export async function runHealthChecks(params) {
236
258
  const slowThresholdMs = Number.isFinite(params.slowThresholdMs) && params.slowThresholdMs && params.slowThresholdMs > 0
237
259
  ? Math.trunc(params.slowThresholdMs)
238
260
  : DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
239
- const cacheKey = createCacheKey({ ...params, mode, timeoutMs, slowThresholdMs, baseUrl: params.baseUrl });
261
+ const cacheKey = createCacheKey({
262
+ ...params,
263
+ mode,
264
+ timeoutMs,
265
+ slowThresholdMs,
266
+ baseUrl: params.baseUrl,
267
+ });
240
268
  const cached = healthCheckCache.get(cacheKey);
241
269
  const startedAt = now();
242
270
  if (!params.fresh && cached && cached.expiresAt > startedAt) {
@@ -0,0 +1,32 @@
1
+ const textEncoder = new TextEncoder();
2
+ export function readOperationalToken(headers) {
3
+ const authorization = headers.get('Authorization') || '';
4
+ if (authorization.toLowerCase().startsWith('bearer ')) {
5
+ return authorization.slice('bearer '.length).trim();
6
+ }
7
+ return (headers.get('x-health-check-key') || '').trim();
8
+ }
9
+ async function digestToken(token) {
10
+ return crypto.subtle.digest('SHA-256', textEncoder.encode(token));
11
+ }
12
+ async function compareDigests(left, right) {
13
+ const timingSafeEqual = crypto.subtle.timingSafeEqual;
14
+ if (typeof timingSafeEqual === 'function') {
15
+ return timingSafeEqual.call(crypto.subtle, left, right);
16
+ }
17
+ // Node Web Crypto에는 Workers 확장 메서드가 없으므로 네이티브 HMAC 검증으로 비교한다.
18
+ const key = await crypto.subtle.importKey('raw', right, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
19
+ const signature = await crypto.subtle.sign('HMAC', key, right);
20
+ return crypto.subtle.verify('HMAC', key, signature, left);
21
+ }
22
+ export async function authorizeOperationalRequest(headers, configuredSecret) {
23
+ const secret = configuredSecret?.trim();
24
+ if (!secret) {
25
+ return 'not-configured';
26
+ }
27
+ const [requestDigest, secretDigest] = await Promise.all([
28
+ digestToken(readOperationalToken(headers)),
29
+ digestToken(secret),
30
+ ]);
31
+ return (await compareDigests(requestDigest, secretDigest)) ? 'authorized' : 'unauthorized';
32
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 디트릭스 GET API 라우트 등록
3
+ */
4
+ import { withEdgeCache } from '../../utils/cache.js';
5
+ import { handleDtryxGetRemainingSeats, handleDtryxListCinemas, handleDtryxListNowShowing, } from '../dtryxHandlers.js';
6
+ export function registerDtryxRoutes(app) {
7
+ app.get('/api/dtryx/cinemas', async (c) => withEdgeCache(c.req.url, {
8
+ ttlSeconds: 60 * 60 * 24,
9
+ staleWhileRevalidateSeconds: 60 * 5,
10
+ keyPrefix: 'dtryx-cinemas-v1',
11
+ }, () => handleDtryxListCinemas(c)));
12
+ app.get('/api/dtryx/movies', async (c) => withEdgeCache(c.req.url, {
13
+ ttlSeconds: 60 * 10,
14
+ staleWhileRevalidateSeconds: 60,
15
+ keyPrefix: 'dtryx-movies-v1',
16
+ }, () => handleDtryxListNowShowing(c)));
17
+ app.get('/api/dtryx/seats', async (c) => withEdgeCache(c.req.url, {
18
+ ttlSeconds: 60 * 3,
19
+ staleWhileRevalidateSeconds: 30,
20
+ keyPrefix: 'dtryx-seats-v1',
21
+ }, () => handleDtryxGetRemainingSeats(c)));
22
+ }
@@ -3,17 +3,11 @@
3
3
  */
4
4
  import { errorResponse } from '../response.js';
5
5
  import { runHealthChecks } from '../healthChecks.js';
6
+ import { authorizeOperationalRequest } from '../operationalAuth.js';
6
7
  function parseBoolean(value) {
7
8
  const normalized = (value || '').trim().toLowerCase();
8
9
  return normalized === 'true' || normalized === '1' || normalized === 'y';
9
10
  }
10
- function readHealthCheckToken(headers) {
11
- const authorization = headers.get('Authorization') || '';
12
- if (authorization.toLowerCase().startsWith('bearer ')) {
13
- return authorization.slice('bearer '.length).trim();
14
- }
15
- return (headers.get('x-health-check-key') || '').trim();
16
- }
17
11
  function isBetterStackHealthProbe(userAgent) {
18
12
  const normalized = (userAgent || '').toLowerCase();
19
13
  return normalized.includes('better stack better uptime bot') || normalized === 'nginx-ssl early hints';
@@ -24,6 +18,13 @@ function isEarlyHintsProbe(userAgent) {
24
18
  function canForceFresh(headers) {
25
19
  return parseBoolean(headers.get('x-health-check-force-fresh') || undefined);
26
20
  }
21
+ function isTrustedHealthTarget(target, requestUrl, configuredBaseUrl) {
22
+ const allowedOrigins = new Set([new URL(requestUrl).origin]);
23
+ if (configuredBaseUrl) {
24
+ allowedOrigins.add(new URL(configuredBaseUrl).origin);
25
+ }
26
+ return allowedOrigins.has(new URL(String(target)).origin);
27
+ }
27
28
  function parseMode(value) {
28
29
  if (!value || value === 'quick') {
29
30
  return 'quick';
@@ -43,7 +44,7 @@ export function registerHealthRoutes(app) {
43
44
  if (!secret) {
44
45
  return errorResponse(c, 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', 503);
45
46
  }
46
- if (readHealthCheckToken(c.req.raw.headers) !== secret) {
47
+ if ((await authorizeOperationalRequest(c.req.raw.headers, secret)) !== 'authorized') {
47
48
  return errorResponse(c, 'UNAUTHORIZED_HEALTH_CHECK', '유효한 헬스 체크 시크릿 키가 필요합니다.', 401);
48
49
  }
49
50
  const mode = parseMode(c.req.query('mode'));
@@ -55,9 +56,16 @@ export function registerHealthRoutes(app) {
55
56
  const baseUrl = c.req.query('baseUrl')?.trim() || c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin;
56
57
  const transport = c.req.query('transport') || c.env?.HEALTH_CHECK_TRANSPORT || 'internal';
57
58
  const cacheBust = parseBoolean(c.req.query('cacheBust')) || transport === 'network';
58
- const fetchImpl = transport !== 'network'
59
- ? async (input, init) => app.fetch(new Request(input, init), c.env)
60
- : undefined;
59
+ const fetchImpl = async (input, init) => {
60
+ const headers = new Headers(init?.headers);
61
+ if (isTrustedHealthTarget(input, c.req.url, c.env?.HEALTH_CHECK_BASE_URL)) {
62
+ headers.set('x-health-check-key', secret);
63
+ }
64
+ const requestInit = { ...init, headers };
65
+ return transport !== 'network'
66
+ ? app.fetch(new Request(input, requestInit), c.env)
67
+ : globalThis.fetch(input, requestInit);
68
+ };
61
69
  const result = await runHealthChecks({
62
70
  baseUrl,
63
71
  service: c.req.query('service') || undefined,
@@ -0,0 +1,274 @@
1
+ /**
2
+ * 인증된 일일 호출 제한 집계 조회 라우트
3
+ */
4
+ import { RATE_LIMIT_METRICS_LEDGER_NAME, RATE_LIMIT_METRICS_RETENTION_DAYS, RATE_LIMIT_SERVICES, } from '../../durableObjects/rateLimitMetricsStore.js';
5
+ import { authorizeOperationalRequest } from '../operationalAuth.js';
6
+ import { errorResponse, successResponse } from '../response.js';
7
+ const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
8
+ const DEFAULT_RANGE_DAYS = 7;
9
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
10
+ const ALLOWED_QUERY_KEYS = new Set(['from', 'to', 'service']);
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ function isExactDate(value) {
15
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
16
+ return false;
17
+ }
18
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
19
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
20
+ }
21
+ function isRateLimitService(value) {
22
+ return typeof value === 'string' && RATE_LIMIT_SERVICES.includes(value);
23
+ }
24
+ function toKstDay(nowMs) {
25
+ return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10);
26
+ }
27
+ function addCalendarDays(day, days) {
28
+ const date = new Date(`${day}T00:00:00.000Z`);
29
+ date.setUTCDate(date.getUTCDate() + days);
30
+ return date.toISOString().slice(0, 10);
31
+ }
32
+ function inclusiveDayCount(from, to) {
33
+ const fromMs = Date.parse(`${from}T00:00:00.000Z`);
34
+ const toMs = Date.parse(`${to}T00:00:00.000Z`);
35
+ return (toMs - fromMs) / (24 * 60 * 60 * 1000) + 1;
36
+ }
37
+ function hasStrictQueryShape(searchParams) {
38
+ const counts = new Map();
39
+ for (const [key] of searchParams) {
40
+ if (!ALLOWED_QUERY_KEYS.has(key)) {
41
+ return false;
42
+ }
43
+ const count = (counts.get(key) ?? 0) + 1;
44
+ if (count > 1) {
45
+ return false;
46
+ }
47
+ counts.set(key, count);
48
+ }
49
+ return counts.has('from') === counts.has('to');
50
+ }
51
+ function parseStatsInput(url, nowMs) {
52
+ if (!hasStrictQueryShape(url.searchParams)) {
53
+ return undefined;
54
+ }
55
+ const serviceValue = url.searchParams.get('service');
56
+ if (serviceValue !== null && !isRateLimitService(serviceValue)) {
57
+ return undefined;
58
+ }
59
+ const currentDay = toKstDay(nowMs);
60
+ const earliestDay = addCalendarDays(currentDay, -(RATE_LIMIT_METRICS_RETENTION_DAYS - 1));
61
+ const fromValue = url.searchParams.get('from');
62
+ const toValue = url.searchParams.get('to');
63
+ const from = fromValue ?? addCalendarDays(currentDay, -(DEFAULT_RANGE_DAYS - 1));
64
+ const to = toValue ?? currentDay;
65
+ if (!isExactDate(from) ||
66
+ !isExactDate(to) ||
67
+ from > to ||
68
+ from < earliestDay ||
69
+ to > currentDay ||
70
+ inclusiveDayCount(from, to) > RATE_LIMIT_METRICS_RETENTION_DAYS) {
71
+ return undefined;
72
+ }
73
+ return serviceValue === null
74
+ ? { from, to, asOfDay: currentDay }
75
+ : { from, to, asOfDay: currentDay, service: serviceValue };
76
+ }
77
+ function sanitizeAggregate(value) {
78
+ if (!isRecord(value)) {
79
+ return undefined;
80
+ }
81
+ const { blockedRequests, uniqueIdentities } = value;
82
+ if (typeof blockedRequests !== 'number' ||
83
+ !Number.isSafeInteger(blockedRequests) ||
84
+ blockedRequests < 0 ||
85
+ typeof uniqueIdentities !== 'number' ||
86
+ !Number.isSafeInteger(uniqueIdentities) ||
87
+ uniqueIdentities < 0 ||
88
+ uniqueIdentities > blockedRequests) {
89
+ return undefined;
90
+ }
91
+ return { blockedRequests, uniqueIdentities };
92
+ }
93
+ function sanitizeDailyRows(value, input) {
94
+ if (!Array.isArray(value)) {
95
+ return undefined;
96
+ }
97
+ const rows = [];
98
+ let previousDay;
99
+ for (const valueRow of value) {
100
+ if (!isRecord(valueRow)) {
101
+ return undefined;
102
+ }
103
+ const aggregate = sanitizeAggregate(valueRow);
104
+ const { day } = valueRow;
105
+ if (!aggregate ||
106
+ !isExactDate(day) ||
107
+ day < input.from ||
108
+ day > input.to ||
109
+ (previousDay !== undefined && day <= previousDay)) {
110
+ return undefined;
111
+ }
112
+ rows.push({ day, ...aggregate });
113
+ previousDay = day;
114
+ }
115
+ return rows;
116
+ }
117
+ function sanitizeServiceRows(value, input) {
118
+ if (!Array.isArray(value)) {
119
+ return undefined;
120
+ }
121
+ const rows = [];
122
+ let previousKey;
123
+ for (const valueRow of value) {
124
+ if (!isRecord(valueRow)) {
125
+ return undefined;
126
+ }
127
+ const aggregate = sanitizeAggregate(valueRow);
128
+ const { day, service } = valueRow;
129
+ const key = `${String(day)}\u0000${String(service)}`;
130
+ if (!aggregate ||
131
+ !isExactDate(day) ||
132
+ day < input.from ||
133
+ day > input.to ||
134
+ !isRateLimitService(service) ||
135
+ (input.service !== undefined && service !== input.service) ||
136
+ (previousKey !== undefined && key <= previousKey)) {
137
+ return undefined;
138
+ }
139
+ rows.push({ day, service, ...aggregate });
140
+ previousKey = key;
141
+ }
142
+ return rows;
143
+ }
144
+ function sumBlockedRequests(rows) {
145
+ let total = 0;
146
+ for (const row of rows) {
147
+ total += row.blockedRequests;
148
+ if (!Number.isSafeInteger(total)) {
149
+ return undefined;
150
+ }
151
+ }
152
+ return total;
153
+ }
154
+ function hasValidGroupedRelations(stats, input) {
155
+ const { totals, daily, services } = stats;
156
+ if (totals.blockedRequests === 0) {
157
+ return totals.uniqueIdentities === 0 && daily.length === 0 && services.length === 0;
158
+ }
159
+ const dailyUniqueSum = daily.reduce((sum, row) => sum + row.uniqueIdentities, 0);
160
+ if (totals.uniqueIdentities > dailyUniqueSum ||
161
+ daily.some((row) => row.blockedRequests === 0 ||
162
+ row.uniqueIdentities === 0 ||
163
+ row.uniqueIdentities > totals.uniqueIdentities) ||
164
+ services.some((row) => row.blockedRequests === 0 ||
165
+ row.uniqueIdentities === 0 ||
166
+ row.uniqueIdentities > totals.uniqueIdentities)) {
167
+ return false;
168
+ }
169
+ const dailyByDay = new Map(daily.map((row) => [row.day, row]));
170
+ const servicesByDay = new Map(daily.map((row) => [row.day, []]));
171
+ for (const row of services) {
172
+ const dailyRow = dailyByDay.get(row.day);
173
+ if (!dailyRow || row.uniqueIdentities > dailyRow.uniqueIdentities) {
174
+ return false;
175
+ }
176
+ servicesByDay.get(row.day).push(row);
177
+ }
178
+ return daily.every((dailyRow) => {
179
+ const dayServices = servicesByDay.get(dailyRow.day);
180
+ if (input.service !== undefined) {
181
+ const serviceRow = dayServices[0];
182
+ return (dayServices.length === 1 &&
183
+ serviceRow?.service === input.service &&
184
+ serviceRow.blockedRequests === dailyRow.blockedRequests &&
185
+ serviceRow.uniqueIdentities === dailyRow.uniqueIdentities);
186
+ }
187
+ const blockedRequests = dayServices.reduce((sum, row) => sum + row.blockedRequests, 0);
188
+ const uniqueIdentities = dayServices.map((row) => row.uniqueIdentities);
189
+ const uniqueSum = uniqueIdentities.reduce((sum, count) => sum + count, 0);
190
+ const uniqueMaximum = Math.max(0, ...uniqueIdentities);
191
+ return (blockedRequests === dailyRow.blockedRequests &&
192
+ dailyRow.uniqueIdentities >= uniqueMaximum &&
193
+ dailyRow.uniqueIdentities <= uniqueSum);
194
+ });
195
+ }
196
+ function sanitizeStats(value, input) {
197
+ if (!isRecord(value)) {
198
+ return undefined;
199
+ }
200
+ const totals = sanitizeAggregate(value.totals);
201
+ const daily = sanitizeDailyRows(value.daily, input);
202
+ const services = sanitizeServiceRows(value.services, input);
203
+ if (!totals || !daily || !services) {
204
+ return undefined;
205
+ }
206
+ if (sumBlockedRequests(daily) !== totals.blockedRequests ||
207
+ sumBlockedRequests(services) !== totals.blockedRequests) {
208
+ return undefined;
209
+ }
210
+ const stats = { totals, daily, services };
211
+ return hasValidGroupedRelations(stats, input) ? stats : undefined;
212
+ }
213
+ function unavailableResponse(c) {
214
+ return errorResponse(c, 'RATE_LIMIT_STATS_UNAVAILABLE', '호출 제한 통계를 조회할 수 없습니다.', 503);
215
+ }
216
+ export function registerRateLimitStatsRoutes(app) {
217
+ app.get('/api/rate-limit/stats', async (c) => {
218
+ const authorization = await authorizeOperationalRequest(c.req.raw.headers, c.env?.HEALTH_CHECK_SECRET);
219
+ if (authorization === 'not-configured') {
220
+ return errorResponse(c, 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', 503);
221
+ }
222
+ if (authorization === 'unauthorized') {
223
+ return errorResponse(c, 'UNAUTHORIZED_RATE_LIMIT_STATS', '유효한 운영 시크릿 키가 필요합니다.', 401);
224
+ }
225
+ const input = parseStatsInput(new URL(c.req.url), Date.now());
226
+ if (!input) {
227
+ return errorResponse(c, 'INVALID_RATE_LIMIT_STATS_QUERY', '통계 조회 조건이 올바르지 않습니다.', 400);
228
+ }
229
+ const namespace = c.env?.DAILY_RATE_LIMITER;
230
+ if (!namespace) {
231
+ console.error('호출 제한 통계 binding 누락');
232
+ return unavailableResponse(c);
233
+ }
234
+ let response;
235
+ try {
236
+ const id = namespace.idFromName(RATE_LIMIT_METRICS_LEDGER_NAME);
237
+ const stub = namespace.get(id);
238
+ const backendUrl = new URL('https://daily-rate-limit/stats');
239
+ backendUrl.searchParams.set('from', input.from);
240
+ backendUrl.searchParams.set('to', input.to);
241
+ backendUrl.searchParams.set('asOf', input.asOfDay);
242
+ if (input.service !== undefined) {
243
+ backendUrl.searchParams.set('service', input.service);
244
+ }
245
+ response = await stub.fetch(backendUrl.toString(), { method: 'GET' });
246
+ }
247
+ catch {
248
+ console.error('호출 제한 통계 Durable Object 호출 실패');
249
+ return unavailableResponse(c);
250
+ }
251
+ if (response.status !== 200) {
252
+ console.error('호출 제한 통계 Durable Object 응답 실패', response.status);
253
+ return unavailableResponse(c);
254
+ }
255
+ let value;
256
+ try {
257
+ value = await response.json();
258
+ }
259
+ catch {
260
+ console.error('호출 제한 통계 응답 검증 실패');
261
+ return unavailableResponse(c);
262
+ }
263
+ const stats = sanitizeStats(value, input);
264
+ if (!stats) {
265
+ console.error('호출 제한 통계 응답 검증 실패');
266
+ return unavailableResponse(c);
267
+ }
268
+ if (toKstDay(Date.now()) !== input.asOfDay) {
269
+ console.error('호출 제한 통계 날짜 전환 감지');
270
+ return unavailableResponse(c);
271
+ }
272
+ return successResponse(c, stats);
273
+ });
274
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 도메인별 GET API 라우트 일괄 등록
3
+ *
4
+ * `src/index.ts` 의 길이 제한(450줄)을 유지하기 위해 분리했습니다.
5
+ */
6
+ import { registerCgvRoutes } from './cgvRoutes.js';
7
+ import { registerCompareRoutes } from './compareRoutes.js';
8
+ import { registerCuRoutes } from './cuRoutes.js';
9
+ import { registerDaisoRoutes } from './daisoRoutes.js';
10
+ import { registerDtryxRoutes } from './dtryxRoutes.js';
11
+ import { registerEmart24Routes } from './emart24Routes.js';
12
+ import { registerFeedbackRoutes } from './feedbackRoutes.js';
13
+ import { registerGs25Routes } from './gs25Routes.js';
14
+ import { registerLotteCinemaRoutes } from './lottecinemaRoutes.js';
15
+ import { registerLotteMartRoutes } from './lottemartRoutes.js';
16
+ import { registerMegaboxRoutes } from './megaboxRoutes.js';
17
+ import { registerOliveyoungRoutes } from './oliveyoungRoutes.js';
18
+ import { registerOpinetRoutes } from './opinetRoutes.js';
19
+ import { registerPlacesRoutes } from './placesRoutes.js';
20
+ import { registerSevenElevenRoutes } from './sevenelevenRoutes.js';
21
+ export function registerServiceRoutes(app) {
22
+ registerDaisoRoutes(app);
23
+ registerGs25Routes(app);
24
+ registerSevenElevenRoutes(app);
25
+ registerCompareRoutes(app);
26
+ registerFeedbackRoutes(app);
27
+ registerPlacesRoutes(app);
28
+ registerOpinetRoutes(app);
29
+ registerCuRoutes(app);
30
+ registerEmart24Routes(app);
31
+ registerLotteMartRoutes(app);
32
+ registerOliveyoungRoutes(app);
33
+ registerDtryxRoutes(app);
34
+ registerMegaboxRoutes(app);
35
+ registerLotteCinemaRoutes(app);
36
+ registerCgvRoutes(app);
37
+ }