daiso 1.0.10 → 1.2.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 (88) hide show
  1. package/README.md +77 -5
  2. package/dist/api/actionsProxy.js +3 -0
  3. package/dist/api/cgvHandlers.js +15 -3
  4. package/dist/api/configStatus.js +2 -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 +13 -5
  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 +29 -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 +18 -31
  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 +13 -0
  38. package/dist/services/cgv/transport.js +5 -22
  39. package/dist/services/cu/client.js +44 -30
  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 +11 -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 +2 -14
  59. package/dist/services/gs25/storeStockTransport.js +30 -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 +33 -201
  65. package/dist/services/lottemart/socketTransport.js +149 -0
  66. package/dist/services/oliveyoung/client.js +7 -39
  67. package/dist/services/oliveyoung/index.js +4 -4
  68. package/dist/services/oliveyoung/tools/checkInventory.js +6 -3
  69. package/dist/services/oliveyoung/tools/findNearbyStores.js +4 -3
  70. package/dist/services/oliveyoung/tools/searchProducts.js +4 -3
  71. package/dist/services/oliveyoung/transport.js +46 -0
  72. package/dist/services/seveneleven/client.js +20 -9
  73. package/dist/services/seveneleven/index.js +11 -7
  74. package/dist/services/seveneleven/inventory.js +5 -2
  75. package/dist/services/seveneleven/productKeyword.js +3 -2
  76. package/dist/services/seveneleven/readCache.js +49 -0
  77. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  78. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  79. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  80. package/dist/services/seveneleven/tools/searchProducts.js +4 -3
  81. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  82. package/dist/utils/format.js +10 -3
  83. package/dist/utils/http.js +40 -12
  84. package/dist/utils/zyte.js +4 -101
  85. package/dist/utils/zyteJsonFallback.js +28 -0
  86. package/openapi.json +51 -3
  87. package/openapi.yaml +36 -3
  88. package/package.json +28 -27
@@ -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
  }
@@ -65,6 +65,9 @@ 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) {
@@ -82,18 +85,6 @@ function shouldDegradeCliContractPath(path, message) {
82
85
  }
83
86
  return false;
84
87
  }
85
- function resolveCheckTimeoutMs(check, timeoutMs) {
86
- if (check.timeoutMs === undefined) {
87
- return timeoutMs;
88
- }
89
- return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
90
- }
91
- function resolveCliContractTimeoutMs(path, timeoutMs) {
92
- if (path.startsWith('/api/oliveyoung/')) {
93
- return Math.min(timeoutMs, 5000);
94
- }
95
- return timeoutMs;
96
- }
97
88
  function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
98
89
  const url = new URL(check.path, baseUrl);
99
90
  url.searchParams.set('timeoutMs', String(timeoutMs));
@@ -131,7 +122,7 @@ async function runCliContractCheck(check, params) {
131
122
  const cacheBustValue = params.cacheBust ? startedAt : undefined;
132
123
  const degradedMessages = [];
133
124
  for (const path of CLI_CONTRACT_PATHS) {
134
- const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
125
+ const checkTimeoutMs = params.timeoutMs;
135
126
  const syntheticCheck = { ...check, path };
136
127
  try {
137
128
  const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
@@ -181,7 +172,7 @@ async function runSingleCheck(check, params) {
181
172
  if (check.kind === 'cli-contract') {
182
173
  return runCliContractCheck(check, params);
183
174
  }
184
- const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs);
175
+ const timeoutMs = params.timeoutMs;
185
176
  const startedAt = params.now();
186
177
  const cacheBustValue = params.cacheBust ? startedAt : undefined;
187
178
  try {
@@ -202,19 +193,41 @@ async function runSingleCheck(check, params) {
202
193
  message,
203
194
  };
204
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
+ }
205
209
  const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
206
210
  const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
207
211
  const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
208
212
  const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
209
- 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';
210
221
  const first = params.includeSamples ? toFirstName(body.data) : undefined;
211
222
  const message = slow
212
223
  ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
213
- : !shapeOk && count !== 0
214
- ? `response missing required fields: ${check.requiredFields.join(', ')}`
224
+ : optionalEmpty
225
+ ? 'optional data unavailable'
215
226
  : count === null
216
- ? 'response ok'
217
- : `${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`;
218
231
  return {
219
232
  id: check.id,
220
233
  service: check.service,
@@ -245,7 +258,13 @@ export async function runHealthChecks(params) {
245
258
  const slowThresholdMs = Number.isFinite(params.slowThresholdMs) && params.slowThresholdMs && params.slowThresholdMs > 0
246
259
  ? Math.trunc(params.slowThresholdMs)
247
260
  : DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
248
- 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
+ });
249
268
  const cached = healthCheckCache.get(cacheKey);
250
269
  const startedAt = now();
251
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
+ }
@@ -36,7 +36,7 @@ export async function handleSevenElevenSearchProducts(c) {
36
36
  page,
37
37
  size,
38
38
  sort,
39
- });
39
+ }, { zyteApiKey: c.env?.ZYTE_API_KEY });
40
40
  return successResponse(c, {
41
41
  query: result.query,
42
42
  count: result.products.length,
@@ -69,7 +69,7 @@ export async function handleSevenElevenSearchStores(c) {
69
69
  const result = await fetchSevenElevenStoresByKeyword({
70
70
  keyword,
71
71
  limit: safeLimit,
72
- });
72
+ }, { zyteApiKey: c.env?.ZYTE_API_KEY });
73
73
  return successResponse(c, {
74
74
  keyword: result.query,
75
75
  count: result.stores.length,
@@ -107,6 +107,7 @@ export async function handleSevenElevenCheckInventory(c) {
107
107
  storeLimit: safeStoreLimit,
108
108
  }, {
109
109
  timeout: safeTimeoutMs,
110
+ zyteApiKey: c.env?.ZYTE_API_KEY,
110
111
  });
111
112
  const note = result.stockAvailable
112
113
  ? '실시간 재고 데이터가 포함되어 있습니다.'
@@ -143,7 +144,9 @@ export async function handleSevenElevenCheckInventory(c) {
143
144
  export async function handleSevenElevenGetSearchPopwords(c) {
144
145
  const label = c.req.query('label') || 'home';
145
146
  try {
146
- const keywords = await fetchSevenElevenSearchPopwords(label);
147
+ const keywords = await fetchSevenElevenSearchPopwords(label, {
148
+ zyteApiKey: c.env?.ZYTE_API_KEY,
149
+ });
147
150
  return successResponse(c, {
148
151
  label,
149
152
  available: keywords.length > 0,
@@ -171,6 +174,7 @@ export async function handleSevenElevenGetCatalogSnapshot(c) {
171
174
  const result = await fetchSevenElevenCatalogSnapshot({
172
175
  includeIssues,
173
176
  includeExhibition,
177
+ zyteApiKey: c.env?.ZYTE_API_KEY,
174
178
  });
175
179
  return successResponse(c, {
176
180
  options: {