@iflow-mcp/jakeliume-webpeel 0.22.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 (547) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +313 -0
  3. package/dist/cache.d.ts +30 -0
  4. package/dist/cache.js +139 -0
  5. package/dist/cli/commands/auth.d.ts +5 -0
  6. package/dist/cli/commands/auth.js +411 -0
  7. package/dist/cli/commands/doctor.d.ts +37 -0
  8. package/dist/cli/commands/doctor.js +371 -0
  9. package/dist/cli/commands/fetch.d.ts +6 -0
  10. package/dist/cli/commands/fetch.js +1345 -0
  11. package/dist/cli/commands/guide.d.ts +2 -0
  12. package/dist/cli/commands/guide.js +183 -0
  13. package/dist/cli/commands/interact.d.ts +5 -0
  14. package/dist/cli/commands/interact.js +840 -0
  15. package/dist/cli/commands/jobs.d.ts +5 -0
  16. package/dist/cli/commands/jobs.js +997 -0
  17. package/dist/cli/commands/monitor.d.ts +12 -0
  18. package/dist/cli/commands/monitor.js +197 -0
  19. package/dist/cli/commands/observe.d.ts +12 -0
  20. package/dist/cli/commands/observe.js +158 -0
  21. package/dist/cli/commands/screenshot.d.ts +5 -0
  22. package/dist/cli/commands/screenshot.js +282 -0
  23. package/dist/cli/commands/search.d.ts +5 -0
  24. package/dist/cli/commands/search.js +1021 -0
  25. package/dist/cli/commands/setup.d.ts +13 -0
  26. package/dist/cli/commands/setup.js +244 -0
  27. package/dist/cli/commands/skill.d.ts +15 -0
  28. package/dist/cli/commands/skill.js +195 -0
  29. package/dist/cli/utils.d.ts +84 -0
  30. package/dist/cli/utils.js +806 -0
  31. package/dist/cli-auth.d.ts +75 -0
  32. package/dist/cli-auth.js +369 -0
  33. package/dist/cli.d.ts +17 -0
  34. package/dist/cli.js +99 -0
  35. package/dist/core/actions.d.ts +69 -0
  36. package/dist/core/actions.js +495 -0
  37. package/dist/core/agent.d.ts +98 -0
  38. package/dist/core/agent.js +558 -0
  39. package/dist/core/answer.d.ts +42 -0
  40. package/dist/core/answer.js +395 -0
  41. package/dist/core/application-tracker.d.ts +84 -0
  42. package/dist/core/application-tracker.js +184 -0
  43. package/dist/core/apply.d.ts +162 -0
  44. package/dist/core/apply.js +816 -0
  45. package/dist/core/auth-detection.d.ts +35 -0
  46. package/dist/core/auth-detection.js +358 -0
  47. package/dist/core/auto-extract.d.ts +82 -0
  48. package/dist/core/auto-extract.js +604 -0
  49. package/dist/core/auto-interact.d.ts +23 -0
  50. package/dist/core/auto-interact.js +246 -0
  51. package/dist/core/bm25-filter.d.ts +66 -0
  52. package/dist/core/bm25-filter.js +288 -0
  53. package/dist/core/branding.d.ts +54 -0
  54. package/dist/core/branding.js +234 -0
  55. package/dist/core/browser-fetch.d.ts +323 -0
  56. package/dist/core/browser-fetch.js +1600 -0
  57. package/dist/core/browser-pool.d.ts +91 -0
  58. package/dist/core/browser-pool.js +550 -0
  59. package/dist/core/budget.d.ts +42 -0
  60. package/dist/core/budget.js +324 -0
  61. package/dist/core/business-intel.d.ts +47 -0
  62. package/dist/core/business-intel.js +279 -0
  63. package/dist/core/cache.d.ts +13 -0
  64. package/dist/core/cache.js +121 -0
  65. package/dist/core/cf-worker-proxy.d.ts +32 -0
  66. package/dist/core/cf-worker-proxy.js +87 -0
  67. package/dist/core/challenge-detection.d.ts +26 -0
  68. package/dist/core/challenge-detection.js +468 -0
  69. package/dist/core/change-tracking.d.ts +75 -0
  70. package/dist/core/change-tracking.js +276 -0
  71. package/dist/core/chunker.d.ts +46 -0
  72. package/dist/core/chunker.js +249 -0
  73. package/dist/core/chunking.d.ts +42 -0
  74. package/dist/core/chunking.js +181 -0
  75. package/dist/core/circuit-breaker.d.ts +44 -0
  76. package/dist/core/circuit-breaker.js +85 -0
  77. package/dist/core/content-pruner.d.ts +47 -0
  78. package/dist/core/content-pruner.js +425 -0
  79. package/dist/core/cookie-cache.d.ts +60 -0
  80. package/dist/core/cookie-cache.js +163 -0
  81. package/dist/core/crawl-checkpoint.d.ts +54 -0
  82. package/dist/core/crawl-checkpoint.js +104 -0
  83. package/dist/core/crawler.d.ts +84 -0
  84. package/dist/core/crawler.js +349 -0
  85. package/dist/core/cross-verify.d.ts +27 -0
  86. package/dist/core/cross-verify.js +93 -0
  87. package/dist/core/deep-fetch.d.ts +74 -0
  88. package/dist/core/deep-fetch.js +405 -0
  89. package/dist/core/deep-research.d.ts +141 -0
  90. package/dist/core/deep-research.js +972 -0
  91. package/dist/core/design-analysis.d.ts +70 -0
  92. package/dist/core/design-analysis.js +490 -0
  93. package/dist/core/design-compare.d.ts +38 -0
  94. package/dist/core/design-compare.js +264 -0
  95. package/dist/core/diff.d.ts +61 -0
  96. package/dist/core/diff.js +289 -0
  97. package/dist/core/dns-cache.d.ts +20 -0
  98. package/dist/core/dns-cache.js +198 -0
  99. package/dist/core/documents.d.ts +23 -0
  100. package/dist/core/documents.js +123 -0
  101. package/dist/core/domain-memory.d.ts +66 -0
  102. package/dist/core/domain-memory.js +163 -0
  103. package/dist/core/domain-verify.d.ts +40 -0
  104. package/dist/core/domain-verify.js +379 -0
  105. package/dist/core/engine-ranker.d.ts +112 -0
  106. package/dist/core/engine-ranker.js +395 -0
  107. package/dist/core/extract-inline.d.ts +38 -0
  108. package/dist/core/extract-inline.js +215 -0
  109. package/dist/core/extract-listings.d.ts +38 -0
  110. package/dist/core/extract-listings.js +461 -0
  111. package/dist/core/extract.d.ts +9 -0
  112. package/dist/core/extract.js +139 -0
  113. package/dist/core/fetch-cache.d.ts +57 -0
  114. package/dist/core/fetch-cache.js +95 -0
  115. package/dist/core/fetcher.d.ts +13 -0
  116. package/dist/core/fetcher.js +12 -0
  117. package/dist/core/google-cache.d.ts +29 -0
  118. package/dist/core/google-cache.js +180 -0
  119. package/dist/core/google-serp-parser.d.ts +82 -0
  120. package/dist/core/google-serp-parser.js +287 -0
  121. package/dist/core/hotel-search.d.ts +122 -0
  122. package/dist/core/hotel-search.js +382 -0
  123. package/dist/core/http-fetch.d.ts +72 -0
  124. package/dist/core/http-fetch.js +820 -0
  125. package/dist/core/human.d.ts +175 -0
  126. package/dist/core/human.js +680 -0
  127. package/dist/core/image-caption.d.ts +44 -0
  128. package/dist/core/image-caption.js +271 -0
  129. package/dist/core/jobs.d.ts +75 -0
  130. package/dist/core/jobs.js +634 -0
  131. package/dist/core/json-ld.d.ts +15 -0
  132. package/dist/core/json-ld.js +617 -0
  133. package/dist/core/language-detect.d.ts +18 -0
  134. package/dist/core/language-detect.js +135 -0
  135. package/dist/core/links.d.ts +10 -0
  136. package/dist/core/links.js +44 -0
  137. package/dist/core/llm-extract.d.ts +71 -0
  138. package/dist/core/llm-extract.js +507 -0
  139. package/dist/core/llm-provider.d.ts +100 -0
  140. package/dist/core/llm-provider.js +702 -0
  141. package/dist/core/local-search.d.ts +60 -0
  142. package/dist/core/local-search.js +308 -0
  143. package/dist/core/logger.d.ts +28 -0
  144. package/dist/core/logger.js +104 -0
  145. package/dist/core/map.d.ts +33 -0
  146. package/dist/core/map.js +127 -0
  147. package/dist/core/markdown.d.ts +92 -0
  148. package/dist/core/markdown.js +809 -0
  149. package/dist/core/metadata.d.ts +34 -0
  150. package/dist/core/metadata.js +422 -0
  151. package/dist/core/observe.d.ts +113 -0
  152. package/dist/core/observe.js +395 -0
  153. package/dist/core/ocr.d.ts +12 -0
  154. package/dist/core/ocr.js +33 -0
  155. package/dist/core/paginate.d.ts +31 -0
  156. package/dist/core/paginate.js +106 -0
  157. package/dist/core/pdf.d.ts +8 -0
  158. package/dist/core/pdf.js +25 -0
  159. package/dist/core/peel-tls.d.ts +25 -0
  160. package/dist/core/peel-tls.js +220 -0
  161. package/dist/core/pipeline.d.ts +132 -0
  162. package/dist/core/pipeline.js +1666 -0
  163. package/dist/core/profiles.d.ts +61 -0
  164. package/dist/core/profiles.js +350 -0
  165. package/dist/core/prompt-guard.d.ts +30 -0
  166. package/dist/core/prompt-guard.js +119 -0
  167. package/dist/core/proxy-config.d.ts +90 -0
  168. package/dist/core/proxy-config.js +172 -0
  169. package/dist/core/quick-answer.d.ts +53 -0
  170. package/dist/core/quick-answer.js +833 -0
  171. package/dist/core/rate-governor.d.ts +80 -0
  172. package/dist/core/rate-governor.js +238 -0
  173. package/dist/core/readability.d.ts +57 -0
  174. package/dist/core/readability.js +533 -0
  175. package/dist/core/research.d.ts +66 -0
  176. package/dist/core/research.js +270 -0
  177. package/dist/core/retry.d.ts +60 -0
  178. package/dist/core/retry.js +119 -0
  179. package/dist/core/safe-browsing.d.ts +30 -0
  180. package/dist/core/safe-browsing.js +206 -0
  181. package/dist/core/schema-extraction.d.ts +66 -0
  182. package/dist/core/schema-extraction.js +352 -0
  183. package/dist/core/schema-postprocess.d.ts +32 -0
  184. package/dist/core/schema-postprocess.js +469 -0
  185. package/dist/core/schema-templates.d.ts +19 -0
  186. package/dist/core/schema-templates.js +143 -0
  187. package/dist/core/screenshot.d.ts +224 -0
  188. package/dist/core/screenshot.js +207 -0
  189. package/dist/core/search-engines.d.ts +25 -0
  190. package/dist/core/search-engines.js +182 -0
  191. package/dist/core/search-provider.d.ts +243 -0
  192. package/dist/core/search-provider.js +1629 -0
  193. package/dist/core/searxng-provider.d.ts +35 -0
  194. package/dist/core/searxng-provider.js +105 -0
  195. package/dist/core/selective-evidence.d.ts +151 -0
  196. package/dist/core/selective-evidence.js +389 -0
  197. package/dist/core/site-search.d.ts +44 -0
  198. package/dist/core/site-search.js +252 -0
  199. package/dist/core/sitemap.d.ts +23 -0
  200. package/dist/core/sitemap.js +105 -0
  201. package/dist/core/source-credibility.d.ts +29 -0
  202. package/dist/core/source-credibility.js +584 -0
  203. package/dist/core/source-scoring.d.ts +166 -0
  204. package/dist/core/source-scoring.js +396 -0
  205. package/dist/core/stemmer.d.ts +38 -0
  206. package/dist/core/stemmer.js +509 -0
  207. package/dist/core/strategies.d.ts +104 -0
  208. package/dist/core/strategies.js +1044 -0
  209. package/dist/core/strategy-hooks.d.ts +145 -0
  210. package/dist/core/strategy-hooks.js +74 -0
  211. package/dist/core/structured-extract.d.ts +43 -0
  212. package/dist/core/structured-extract.js +550 -0
  213. package/dist/core/summarize.d.ts +17 -0
  214. package/dist/core/summarize.js +78 -0
  215. package/dist/core/synonyms.d.ts +42 -0
  216. package/dist/core/synonyms.js +184 -0
  217. package/dist/core/system-monitor.d.ts +61 -0
  218. package/dist/core/system-monitor.js +133 -0
  219. package/dist/core/table-format.d.ts +30 -0
  220. package/dist/core/table-format.js +146 -0
  221. package/dist/core/threat-feeds.d.ts +23 -0
  222. package/dist/core/threat-feeds.js +104 -0
  223. package/dist/core/timing.d.ts +21 -0
  224. package/dist/core/timing.js +33 -0
  225. package/dist/core/transcript-export.d.ts +47 -0
  226. package/dist/core/transcript-export.js +107 -0
  227. package/dist/core/user-agents.d.ts +82 -0
  228. package/dist/core/user-agents.js +239 -0
  229. package/dist/core/vertical-search.d.ts +54 -0
  230. package/dist/core/vertical-search.js +158 -0
  231. package/dist/core/watch-manager.d.ts +175 -0
  232. package/dist/core/watch-manager.js +416 -0
  233. package/dist/core/watch.d.ts +101 -0
  234. package/dist/core/watch.js +389 -0
  235. package/dist/core/youtube.d.ts +130 -0
  236. package/dist/core/youtube.js +1175 -0
  237. package/dist/ee/challenge-re-export.d.ts +1 -0
  238. package/dist/ee/challenge-re-export.js +1 -0
  239. package/dist/ee/challenge-solver.d.ts +72 -0
  240. package/dist/ee/challenge-solver.js +720 -0
  241. package/dist/ee/domain-extractors.d.ts +8 -0
  242. package/dist/ee/domain-extractors.js +8 -0
  243. package/dist/ee/domain-intel.d.ts +16 -0
  244. package/dist/ee/domain-intel.js +133 -0
  245. package/dist/ee/extractors/allrecipes.d.ts +2 -0
  246. package/dist/ee/extractors/allrecipes.js +120 -0
  247. package/dist/ee/extractors/amazon.d.ts +2 -0
  248. package/dist/ee/extractors/amazon.js +78 -0
  249. package/dist/ee/extractors/arxiv.d.ts +2 -0
  250. package/dist/ee/extractors/arxiv.js +137 -0
  251. package/dist/ee/extractors/bestbuy.d.ts +2 -0
  252. package/dist/ee/extractors/bestbuy.js +78 -0
  253. package/dist/ee/extractors/carscom.d.ts +2 -0
  254. package/dist/ee/extractors/carscom.js +121 -0
  255. package/dist/ee/extractors/coingecko.d.ts +2 -0
  256. package/dist/ee/extractors/coingecko.js +134 -0
  257. package/dist/ee/extractors/craigslist.d.ts +2 -0
  258. package/dist/ee/extractors/craigslist.js +92 -0
  259. package/dist/ee/extractors/devto.d.ts +2 -0
  260. package/dist/ee/extractors/devto.js +135 -0
  261. package/dist/ee/extractors/ebay.d.ts +2 -0
  262. package/dist/ee/extractors/ebay.js +90 -0
  263. package/dist/ee/extractors/espn.d.ts +2 -0
  264. package/dist/ee/extractors/espn.js +260 -0
  265. package/dist/ee/extractors/etsy.d.ts +2 -0
  266. package/dist/ee/extractors/etsy.js +52 -0
  267. package/dist/ee/extractors/facebook.d.ts +2 -0
  268. package/dist/ee/extractors/facebook.js +46 -0
  269. package/dist/ee/extractors/github.d.ts +2 -0
  270. package/dist/ee/extractors/github.js +196 -0
  271. package/dist/ee/extractors/google-flights.d.ts +2 -0
  272. package/dist/ee/extractors/google-flights.js +176 -0
  273. package/dist/ee/extractors/hackernews.d.ts +2 -0
  274. package/dist/ee/extractors/hackernews.js +147 -0
  275. package/dist/ee/extractors/imdb.d.ts +2 -0
  276. package/dist/ee/extractors/imdb.js +172 -0
  277. package/dist/ee/extractors/index.d.ts +26 -0
  278. package/dist/ee/extractors/index.js +247 -0
  279. package/dist/ee/extractors/instagram.d.ts +2 -0
  280. package/dist/ee/extractors/instagram.js +102 -0
  281. package/dist/ee/extractors/kalshi.d.ts +2 -0
  282. package/dist/ee/extractors/kalshi.js +121 -0
  283. package/dist/ee/extractors/kayak-cars.d.ts +2 -0
  284. package/dist/ee/extractors/kayak-cars.js +270 -0
  285. package/dist/ee/extractors/linkedin.d.ts +2 -0
  286. package/dist/ee/extractors/linkedin.js +113 -0
  287. package/dist/ee/extractors/medium.d.ts +2 -0
  288. package/dist/ee/extractors/medium.js +130 -0
  289. package/dist/ee/extractors/news.d.ts +4 -0
  290. package/dist/ee/extractors/news.js +173 -0
  291. package/dist/ee/extractors/npm.d.ts +2 -0
  292. package/dist/ee/extractors/npm.js +86 -0
  293. package/dist/ee/extractors/pdf.d.ts +2 -0
  294. package/dist/ee/extractors/pdf.js +108 -0
  295. package/dist/ee/extractors/pinterest.d.ts +2 -0
  296. package/dist/ee/extractors/pinterest.js +34 -0
  297. package/dist/ee/extractors/polymarket.d.ts +2 -0
  298. package/dist/ee/extractors/polymarket.js +358 -0
  299. package/dist/ee/extractors/producthunt.d.ts +2 -0
  300. package/dist/ee/extractors/producthunt.js +88 -0
  301. package/dist/ee/extractors/pubmed.d.ts +2 -0
  302. package/dist/ee/extractors/pubmed.js +162 -0
  303. package/dist/ee/extractors/pypi.d.ts +2 -0
  304. package/dist/ee/extractors/pypi.js +80 -0
  305. package/dist/ee/extractors/reddit.d.ts +2 -0
  306. package/dist/ee/extractors/reddit.js +438 -0
  307. package/dist/ee/extractors/redfin.d.ts +2 -0
  308. package/dist/ee/extractors/redfin.js +156 -0
  309. package/dist/ee/extractors/semanticscholar.d.ts +2 -0
  310. package/dist/ee/extractors/semanticscholar.js +131 -0
  311. package/dist/ee/extractors/shared.d.ts +12 -0
  312. package/dist/ee/extractors/shared.js +76 -0
  313. package/dist/ee/extractors/soundcloud.d.ts +2 -0
  314. package/dist/ee/extractors/soundcloud.js +34 -0
  315. package/dist/ee/extractors/sportsbetting.d.ts +2 -0
  316. package/dist/ee/extractors/sportsbetting.js +37 -0
  317. package/dist/ee/extractors/spotify.d.ts +2 -0
  318. package/dist/ee/extractors/spotify.js +34 -0
  319. package/dist/ee/extractors/stackoverflow.d.ts +2 -0
  320. package/dist/ee/extractors/stackoverflow.js +61 -0
  321. package/dist/ee/extractors/substack.d.ts +2 -0
  322. package/dist/ee/extractors/substack.js +115 -0
  323. package/dist/ee/extractors/substackroot.d.ts +2 -0
  324. package/dist/ee/extractors/substackroot.js +46 -0
  325. package/dist/ee/extractors/tiktok.d.ts +2 -0
  326. package/dist/ee/extractors/tiktok.js +29 -0
  327. package/dist/ee/extractors/tradingview.d.ts +2 -0
  328. package/dist/ee/extractors/tradingview.js +182 -0
  329. package/dist/ee/extractors/twitch.d.ts +2 -0
  330. package/dist/ee/extractors/twitch.js +36 -0
  331. package/dist/ee/extractors/twitter.d.ts +2 -0
  332. package/dist/ee/extractors/twitter.js +327 -0
  333. package/dist/ee/extractors/types.d.ts +14 -0
  334. package/dist/ee/extractors/types.js +1 -0
  335. package/dist/ee/extractors/walmart.d.ts +2 -0
  336. package/dist/ee/extractors/walmart.js +50 -0
  337. package/dist/ee/extractors/weather.d.ts +2 -0
  338. package/dist/ee/extractors/weather.js +133 -0
  339. package/dist/ee/extractors/wikipedia.d.ts +4 -0
  340. package/dist/ee/extractors/wikipedia.js +235 -0
  341. package/dist/ee/extractors/yelp.d.ts +2 -0
  342. package/dist/ee/extractors/yelp.js +216 -0
  343. package/dist/ee/extractors/youtube.d.ts +2 -0
  344. package/dist/ee/extractors/youtube.js +189 -0
  345. package/dist/ee/extractors/zillow.d.ts +54 -0
  346. package/dist/ee/extractors/zillow.js +247 -0
  347. package/dist/ee/extractors-re-export.d.ts +1 -0
  348. package/dist/ee/extractors-re-export.js +1 -0
  349. package/dist/ee/premium-hooks.d.ts +20 -0
  350. package/dist/ee/premium-hooks.js +50 -0
  351. package/dist/ee/spa-detection.d.ts +2 -0
  352. package/dist/ee/spa-detection.js +2 -0
  353. package/dist/ee/stability.d.ts +4 -0
  354. package/dist/ee/stability.js +29 -0
  355. package/dist/ee/swr-cache.d.ts +14 -0
  356. package/dist/ee/swr-cache.js +34 -0
  357. package/dist/index.d.ts +143 -0
  358. package/dist/index.js +291 -0
  359. package/dist/integrations/index.d.ts +2 -0
  360. package/dist/integrations/index.js +2 -0
  361. package/dist/integrations/langchain.d.ts +64 -0
  362. package/dist/integrations/langchain.js +115 -0
  363. package/dist/integrations/llamaindex.d.ts +50 -0
  364. package/dist/integrations/llamaindex.js +91 -0
  365. package/dist/mcp/handlers/act.d.ts +5 -0
  366. package/dist/mcp/handlers/act.js +34 -0
  367. package/dist/mcp/handlers/definitions.d.ts +6 -0
  368. package/dist/mcp/handlers/definitions.js +395 -0
  369. package/dist/mcp/handlers/extract.d.ts +7 -0
  370. package/dist/mcp/handlers/extract.js +135 -0
  371. package/dist/mcp/handlers/fetch.d.ts +6 -0
  372. package/dist/mcp/handlers/fetch.js +98 -0
  373. package/dist/mcp/handlers/find.d.ts +5 -0
  374. package/dist/mcp/handlers/find.js +137 -0
  375. package/dist/mcp/handlers/index.d.ts +13 -0
  376. package/dist/mcp/handlers/index.js +63 -0
  377. package/dist/mcp/handlers/legacy.d.ts +25 -0
  378. package/dist/mcp/handlers/legacy.js +450 -0
  379. package/dist/mcp/handlers/meta.d.ts +6 -0
  380. package/dist/mcp/handlers/meta.js +40 -0
  381. package/dist/mcp/handlers/monitor.d.ts +5 -0
  382. package/dist/mcp/handlers/monitor.js +41 -0
  383. package/dist/mcp/handlers/observe.d.ts +8 -0
  384. package/dist/mcp/handlers/observe.js +37 -0
  385. package/dist/mcp/handlers/read.d.ts +6 -0
  386. package/dist/mcp/handlers/read.js +78 -0
  387. package/dist/mcp/handlers/see.d.ts +5 -0
  388. package/dist/mcp/handlers/see.js +75 -0
  389. package/dist/mcp/handlers/types.d.ts +29 -0
  390. package/dist/mcp/handlers/types.js +28 -0
  391. package/dist/mcp/server.d.ts +7 -0
  392. package/dist/mcp/server.js +108 -0
  393. package/dist/mcp/smart-router.d.ts +23 -0
  394. package/dist/mcp/smart-router.js +178 -0
  395. package/dist/server/app.d.ts +14 -0
  396. package/dist/server/app.js +632 -0
  397. package/dist/server/auth-store.d.ts +28 -0
  398. package/dist/server/auth-store.js +88 -0
  399. package/dist/server/bull-queues.d.ts +60 -0
  400. package/dist/server/bull-queues.js +90 -0
  401. package/dist/server/email-service.d.ts +55 -0
  402. package/dist/server/email-service.js +291 -0
  403. package/dist/server/job-queue.d.ts +100 -0
  404. package/dist/server/job-queue.js +145 -0
  405. package/dist/server/logger.d.ts +10 -0
  406. package/dist/server/logger.js +37 -0
  407. package/dist/server/middleware/audit-log.d.ts +14 -0
  408. package/dist/server/middleware/audit-log.js +73 -0
  409. package/dist/server/middleware/auth.d.ts +35 -0
  410. package/dist/server/middleware/auth.js +225 -0
  411. package/dist/server/middleware/rate-limit.d.ts +50 -0
  412. package/dist/server/middleware/rate-limit.js +270 -0
  413. package/dist/server/middleware/scope-guard.d.ts +25 -0
  414. package/dist/server/middleware/scope-guard.js +45 -0
  415. package/dist/server/middleware/url-validator.d.ts +15 -0
  416. package/dist/server/middleware/url-validator.js +201 -0
  417. package/dist/server/openapi.yaml +6418 -0
  418. package/dist/server/pg-auth-store.d.ts +146 -0
  419. package/dist/server/pg-auth-store.js +576 -0
  420. package/dist/server/pg-job-queue.d.ts +59 -0
  421. package/dist/server/pg-job-queue.js +375 -0
  422. package/dist/server/routes/activity.d.ts +6 -0
  423. package/dist/server/routes/activity.js +79 -0
  424. package/dist/server/routes/admin-active.d.ts +7 -0
  425. package/dist/server/routes/admin-active.js +120 -0
  426. package/dist/server/routes/admin-stats.d.ts +7 -0
  427. package/dist/server/routes/admin-stats.js +176 -0
  428. package/dist/server/routes/agent.d.ts +24 -0
  429. package/dist/server/routes/agent.js +480 -0
  430. package/dist/server/routes/answer.d.ts +5 -0
  431. package/dist/server/routes/answer.js +125 -0
  432. package/dist/server/routes/ask.d.ts +28 -0
  433. package/dist/server/routes/ask.js +295 -0
  434. package/dist/server/routes/batch.d.ts +6 -0
  435. package/dist/server/routes/batch.js +493 -0
  436. package/dist/server/routes/cache-warm.d.ts +25 -0
  437. package/dist/server/routes/cache-warm.js +212 -0
  438. package/dist/server/routes/cli-usage.d.ts +6 -0
  439. package/dist/server/routes/cli-usage.js +127 -0
  440. package/dist/server/routes/compat.d.ts +23 -0
  441. package/dist/server/routes/compat.js +652 -0
  442. package/dist/server/routes/crawl.d.ts +13 -0
  443. package/dist/server/routes/crawl.js +287 -0
  444. package/dist/server/routes/deep-fetch.d.ts +8 -0
  445. package/dist/server/routes/deep-fetch.js +57 -0
  446. package/dist/server/routes/deep-research.d.ts +11 -0
  447. package/dist/server/routes/deep-research.js +232 -0
  448. package/dist/server/routes/demo.d.ts +24 -0
  449. package/dist/server/routes/demo.js +517 -0
  450. package/dist/server/routes/do.d.ts +8 -0
  451. package/dist/server/routes/do.js +72 -0
  452. package/dist/server/routes/extract.d.ts +14 -0
  453. package/dist/server/routes/extract.js +325 -0
  454. package/dist/server/routes/feed.d.ts +15 -0
  455. package/dist/server/routes/feed.js +311 -0
  456. package/dist/server/routes/fetch-queue.d.ts +13 -0
  457. package/dist/server/routes/fetch-queue.js +357 -0
  458. package/dist/server/routes/fetch.d.ts +7 -0
  459. package/dist/server/routes/fetch.js +1274 -0
  460. package/dist/server/routes/go.d.ts +14 -0
  461. package/dist/server/routes/go.js +81 -0
  462. package/dist/server/routes/health.d.ts +11 -0
  463. package/dist/server/routes/health.js +141 -0
  464. package/dist/server/routes/jobs.d.ts +7 -0
  465. package/dist/server/routes/jobs.js +574 -0
  466. package/dist/server/routes/map.d.ts +11 -0
  467. package/dist/server/routes/map.js +116 -0
  468. package/dist/server/routes/mcp.d.ts +14 -0
  469. package/dist/server/routes/mcp.js +197 -0
  470. package/dist/server/routes/metrics.d.ts +37 -0
  471. package/dist/server/routes/metrics.js +149 -0
  472. package/dist/server/routes/oauth.d.ts +9 -0
  473. package/dist/server/routes/oauth.js +396 -0
  474. package/dist/server/routes/playground.d.ts +17 -0
  475. package/dist/server/routes/playground.js +283 -0
  476. package/dist/server/routes/reader.d.ts +18 -0
  477. package/dist/server/routes/reader.js +192 -0
  478. package/dist/server/routes/research.d.ts +14 -0
  479. package/dist/server/routes/research.js +482 -0
  480. package/dist/server/routes/screenshot.d.ts +22 -0
  481. package/dist/server/routes/screenshot.js +820 -0
  482. package/dist/server/routes/search.d.ts +6 -0
  483. package/dist/server/routes/search.js +874 -0
  484. package/dist/server/routes/session.d.ts +17 -0
  485. package/dist/server/routes/session.js +548 -0
  486. package/dist/server/routes/share.d.ts +18 -0
  487. package/dist/server/routes/share.js +462 -0
  488. package/dist/server/routes/smart-search/handlers/cars.d.ts +2 -0
  489. package/dist/server/routes/smart-search/handlers/cars.js +102 -0
  490. package/dist/server/routes/smart-search/handlers/flights.d.ts +2 -0
  491. package/dist/server/routes/smart-search/handlers/flights.js +72 -0
  492. package/dist/server/routes/smart-search/handlers/general.d.ts +13 -0
  493. package/dist/server/routes/smart-search/handlers/general.js +717 -0
  494. package/dist/server/routes/smart-search/handlers/hotels.d.ts +2 -0
  495. package/dist/server/routes/smart-search/handlers/hotels.js +88 -0
  496. package/dist/server/routes/smart-search/handlers/products.d.ts +2 -0
  497. package/dist/server/routes/smart-search/handlers/products.js +1309 -0
  498. package/dist/server/routes/smart-search/handlers/rental.d.ts +2 -0
  499. package/dist/server/routes/smart-search/handlers/rental.js +154 -0
  500. package/dist/server/routes/smart-search/handlers/restaurants.d.ts +2 -0
  501. package/dist/server/routes/smart-search/handlers/restaurants.js +225 -0
  502. package/dist/server/routes/smart-search/handlers/transit-verdict.d.ts +41 -0
  503. package/dist/server/routes/smart-search/handlers/transit-verdict.js +224 -0
  504. package/dist/server/routes/smart-search/index.d.ts +19 -0
  505. package/dist/server/routes/smart-search/index.js +546 -0
  506. package/dist/server/routes/smart-search/intent.d.ts +3 -0
  507. package/dist/server/routes/smart-search/intent.js +264 -0
  508. package/dist/server/routes/smart-search/llm.d.ts +16 -0
  509. package/dist/server/routes/smart-search/llm.js +70 -0
  510. package/dist/server/routes/smart-search/sources/reddit.d.ts +18 -0
  511. package/dist/server/routes/smart-search/sources/reddit.js +34 -0
  512. package/dist/server/routes/smart-search/sources/yelp.d.ts +25 -0
  513. package/dist/server/routes/smart-search/sources/yelp.js +171 -0
  514. package/dist/server/routes/smart-search/sources/youtube.d.ts +8 -0
  515. package/dist/server/routes/smart-search/sources/youtube.js +9 -0
  516. package/dist/server/routes/smart-search/types.d.ts +81 -0
  517. package/dist/server/routes/smart-search/types.js +1 -0
  518. package/dist/server/routes/smart-search/utils.d.ts +20 -0
  519. package/dist/server/routes/smart-search/utils.js +146 -0
  520. package/dist/server/routes/stats.d.ts +6 -0
  521. package/dist/server/routes/stats.js +71 -0
  522. package/dist/server/routes/stripe.d.ts +15 -0
  523. package/dist/server/routes/stripe.js +296 -0
  524. package/dist/server/routes/transcript-export.d.ts +10 -0
  525. package/dist/server/routes/transcript-export.js +178 -0
  526. package/dist/server/routes/usage.d.ts +9 -0
  527. package/dist/server/routes/usage.js +279 -0
  528. package/dist/server/routes/users.d.ts +8 -0
  529. package/dist/server/routes/users.js +1867 -0
  530. package/dist/server/routes/watch.d.ts +15 -0
  531. package/dist/server/routes/watch.js +309 -0
  532. package/dist/server/routes/webhooks.d.ts +26 -0
  533. package/dist/server/routes/webhooks.js +170 -0
  534. package/dist/server/routes/youtube.d.ts +6 -0
  535. package/dist/server/routes/youtube.js +130 -0
  536. package/dist/server/sentry.d.ts +14 -0
  537. package/dist/server/sentry.js +104 -0
  538. package/dist/server/types.d.ts +15 -0
  539. package/dist/server/types.js +7 -0
  540. package/dist/server/utils/response.d.ts +44 -0
  541. package/dist/server/utils/response.js +69 -0
  542. package/dist/server/utils/sse.d.ts +22 -0
  543. package/dist/server/utils/sse.js +38 -0
  544. package/dist/types.d.ts +552 -0
  545. package/dist/types.js +39 -0
  546. package/llms.txt +105 -0
  547. package/package.json +189 -0
@@ -0,0 +1,1867 @@
1
+ /**
2
+ * User authentication and API key management routes
3
+ */
4
+ import { Router } from 'express';
5
+ import crypto from 'crypto';
6
+ import bcrypt from 'bcrypt';
7
+ import jwt from 'jsonwebtoken';
8
+ import pg from 'pg';
9
+ import { PostgresAuthStore } from '../pg-auth-store.js';
10
+ import { sendPasswordResetEmail } from '../email-service.js';
11
+ const { Pool } = pg;
12
+ const BCRYPT_ROUNDS = 12;
13
+ /**
14
+ * Per-email rate limiter for login attempts (brute-force protection)
15
+ */
16
+ const loginAttempts = new Map();
17
+ // Clean up expired entries every 15 minutes
18
+ setInterval(() => {
19
+ const now = Date.now();
20
+ for (const [key, attempt] of loginAttempts.entries()) {
21
+ if (now >= attempt.resetAt) {
22
+ loginAttempts.delete(key);
23
+ }
24
+ }
25
+ }, 15 * 60 * 1000);
26
+ function loginRateLimiter(req, res, next) {
27
+ const email = req.body?.email?.toLowerCase();
28
+ if (!email) {
29
+ next();
30
+ return;
31
+ }
32
+ const now = Date.now();
33
+ const attempt = loginAttempts.get(email);
34
+ if (attempt && now < attempt.resetAt) {
35
+ if (attempt.count >= 5) {
36
+ res.status(429).json({
37
+ success: false,
38
+ error: {
39
+ type: 'too_many_attempts',
40
+ message: 'Too many login attempts. Please try again in 15 minutes.',
41
+ hint: 'Wait 15 minutes before trying again.',
42
+ docs: 'https://webpeel.dev/docs/errors#too_many_attempts',
43
+ },
44
+ retryAfter: Math.ceil((attempt.resetAt - now) / 1000),
45
+ requestId: crypto.randomUUID(),
46
+ });
47
+ return;
48
+ }
49
+ attempt.count++;
50
+ }
51
+ else {
52
+ loginAttempts.set(email, { count: 1, resetAt: now + 15 * 60 * 1000 });
53
+ }
54
+ next();
55
+ }
56
+ /**
57
+ * Per-IP rate limiter for refresh endpoint (brute-force protection)
58
+ */
59
+ const refreshAttempts = new Map();
60
+ setInterval(() => {
61
+ const now = Date.now();
62
+ for (const [key, attempt] of refreshAttempts.entries()) {
63
+ if (now >= attempt.resetAt) {
64
+ refreshAttempts.delete(key);
65
+ }
66
+ }
67
+ }, 15 * 60 * 1000);
68
+ function refreshRateLimiter(req, res, next) {
69
+ const ip = req.headers['cf-connecting-ip'] ||
70
+ req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
71
+ req.ip ||
72
+ 'unknown';
73
+ const now = Date.now();
74
+ const attempt = refreshAttempts.get(ip);
75
+ if (attempt && now < attempt.resetAt) {
76
+ if (attempt.count >= 10) {
77
+ res.status(429).json({
78
+ success: false,
79
+ error: {
80
+ type: 'too_many_attempts',
81
+ message: 'Too many refresh attempts. Please try again in 15 minutes.',
82
+ hint: 'Wait 15 minutes before trying again.',
83
+ docs: 'https://webpeel.dev/docs/errors#too_many_attempts',
84
+ },
85
+ retryAfter: Math.ceil((attempt.resetAt - now) / 1000),
86
+ requestId: crypto.randomUUID(),
87
+ });
88
+ return;
89
+ }
90
+ attempt.count++;
91
+ }
92
+ else {
93
+ refreshAttempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
94
+ }
95
+ next();
96
+ }
97
+ /**
98
+ * Validate email format
99
+ */
100
+ function isValidEmail(email) {
101
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
102
+ return emailRegex.test(email);
103
+ }
104
+ /**
105
+ * Validate password strength
106
+ */
107
+ function isValidPassword(password) {
108
+ // bcrypt silently truncates at 72 bytes — enforce a max to prevent confusion
109
+ return password.length >= 8 && password.length <= 128;
110
+ }
111
+ /**
112
+ * JWT authentication middleware
113
+ */
114
+ function jwtAuth(req, res, next) {
115
+ try {
116
+ const authHeader = req.headers.authorization;
117
+ if (!authHeader?.startsWith('Bearer ')) {
118
+ res.status(401).json({
119
+ success: false,
120
+ error: {
121
+ type: 'missing_token',
122
+ message: 'JWT token required. Provide via Authorization: Bearer <token>',
123
+ hint: 'Include your JWT in the Authorization header: Bearer <token>',
124
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
125
+ },
126
+ requestId: crypto.randomUUID(),
127
+ });
128
+ return;
129
+ }
130
+ const token = authHeader.slice(7);
131
+ const jwtSecret = process.env.JWT_SECRET;
132
+ if (!jwtSecret) {
133
+ throw new Error('JWT_SECRET environment variable not configured');
134
+ }
135
+ const payload = jwt.verify(token, jwtSecret);
136
+ // Attach user info to request
137
+ req.user = payload;
138
+ next();
139
+ }
140
+ catch (error) {
141
+ if (error instanceof jwt.JsonWebTokenError) {
142
+ res.status(401).json({
143
+ success: false,
144
+ error: {
145
+ type: 'invalid_token',
146
+ message: 'Invalid or expired JWT token',
147
+ hint: 'Log in again to get a new token.',
148
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
149
+ },
150
+ requestId: crypto.randomUUID(),
151
+ });
152
+ return;
153
+ }
154
+ res.status(500).json({
155
+ success: false,
156
+ error: {
157
+ type: 'auth_error',
158
+ message: 'Authentication failed',
159
+ docs: 'https://webpeel.dev/docs/errors#auth_error',
160
+ },
161
+ requestId: crypto.randomUUID(),
162
+ });
163
+ }
164
+ }
165
+ /**
166
+ * Create user routes
167
+ */
168
+ export function createUserRouter() {
169
+ const router = Router();
170
+ const dbUrl = process.env.DATABASE_URL;
171
+ if (!dbUrl) {
172
+ throw new Error('DATABASE_URL environment variable is required');
173
+ }
174
+ const pool = new Pool({
175
+ connectionString: dbUrl,
176
+ // TLS: enabled when DATABASE_URL contains sslmode=require.
177
+ // Secure by default (rejectUnauthorized: true); set PG_REJECT_UNAUTHORIZED=false
178
+ // only for managed DBs (Render/Neon/Supabase) that use self-signed certs.
179
+ ssl: process.env.DATABASE_URL?.includes('sslmode=require')
180
+ ? { rejectUnauthorized: process.env.PG_REJECT_UNAUTHORIZED !== 'false' }
181
+ : undefined,
182
+ });
183
+ // Initialize refresh_tokens table on startup
184
+ pool.query(`
185
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
186
+ id TEXT PRIMARY KEY,
187
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
188
+ expires_at TIMESTAMPTZ NOT NULL,
189
+ revoked_at TIMESTAMPTZ,
190
+ created_at TIMESTAMPTZ DEFAULT NOW()
191
+ )
192
+ `).then(() => pool.query(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)`)).catch((err) => {
193
+ console.error('Failed to initialize refresh_tokens table:', err);
194
+ });
195
+ /**
196
+ * Helper: generate a refresh token and store its jti in the database
197
+ */
198
+ async function createRefreshToken(userId, jwtSecret) {
199
+ const jti = crypto.randomUUID();
200
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
201
+ await pool.query(`INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, $3)`, [jti, userId, expiresAt]);
202
+ return jwt.sign({ userId, jti }, jwtSecret, { expiresIn: '30d' });
203
+ }
204
+ /**
205
+ * POST /v1/auth/register
206
+ * Register a new user and create their first API key
207
+ */
208
+ router.post('/v1/auth/register', async (req, res) => {
209
+ try {
210
+ const { email, password } = req.body;
211
+ // Input validation
212
+ if (!email || !password) {
213
+ res.status(400).json({
214
+ success: false,
215
+ error: {
216
+ type: 'missing_fields',
217
+ message: 'Email and password are required',
218
+ hint: 'Provide both email and password in the request body.',
219
+ docs: 'https://webpeel.dev/docs/errors#missing_fields',
220
+ },
221
+ requestId: crypto.randomUUID(),
222
+ });
223
+ return;
224
+ }
225
+ if (!isValidEmail(email)) {
226
+ res.status(400).json({
227
+ success: false,
228
+ error: {
229
+ type: 'invalid_email',
230
+ message: 'Invalid email format',
231
+ hint: 'Provide a valid email address (e.g. user@example.com).',
232
+ docs: 'https://webpeel.dev/docs/errors#invalid_email',
233
+ },
234
+ requestId: crypto.randomUUID(),
235
+ });
236
+ return;
237
+ }
238
+ if (!isValidPassword(password)) {
239
+ res.status(400).json({
240
+ success: false,
241
+ error: {
242
+ type: 'weak_password',
243
+ message: 'Password must be at least 8 characters',
244
+ hint: 'Choose a password with at least 8 characters.',
245
+ docs: 'https://webpeel.dev/docs/errors#weak_password',
246
+ },
247
+ requestId: crypto.randomUUID(),
248
+ });
249
+ return;
250
+ }
251
+ // Hash password
252
+ const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
253
+ // Create user
254
+ const userResult = await pool.query(`INSERT INTO users (email, password_hash, tier, weekly_limit, burst_limit, rate_limit)
255
+ VALUES ($1, $2, 'free', 500, 50, 10)
256
+ RETURNING id, email, tier, weekly_limit, burst_limit, rate_limit, created_at`, [email, passwordHash]);
257
+ const user = userResult.rows[0];
258
+ // Generate API key
259
+ const apiKey = PostgresAuthStore.generateApiKey();
260
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
261
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
262
+ // Store API key
263
+ await pool.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
264
+ VALUES ($1, $2, $3, 'Default')`, [user.id, keyHash, keyPrefix]);
265
+ const signupTimestamp = new Date().toISOString();
266
+ res.status(201).json({
267
+ user: {
268
+ id: user.id,
269
+ email: user.email,
270
+ tier: user.tier,
271
+ weeklyLimit: user.weekly_limit,
272
+ burstLimit: user.burst_limit,
273
+ rateLimit: user.rate_limit,
274
+ createdAt: user.created_at,
275
+ },
276
+ apiKey, // SECURITY: Only returned once, never stored or shown again
277
+ });
278
+ // Fire-and-forget Discord webhook for successful signups; never block registration on webhook errors.
279
+ try {
280
+ const webhookUrl = process.env.DISCORD_SIGNUP_WEBHOOK;
281
+ if (webhookUrl) {
282
+ void fetch(webhookUrl, {
283
+ method: 'POST',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify({
286
+ embeds: [{
287
+ title: '🎉 New Signup',
288
+ color: 9133302,
289
+ fields: [
290
+ { name: 'Email', value: email, inline: true },
291
+ { name: 'Tier', value: 'Free', inline: true },
292
+ { name: 'Timestamp', value: signupTimestamp, inline: false },
293
+ ],
294
+ timestamp: signupTimestamp,
295
+ footer: { text: 'WebPeel Signups' },
296
+ }],
297
+ }),
298
+ }).catch(() => { });
299
+ }
300
+ }
301
+ catch (e) {
302
+ if (process.env.DEBUG)
303
+ console.debug('[webpeel]', 'discord webhook failed:', e instanceof Error ? e.message : e);
304
+ }
305
+ }
306
+ catch (error) {
307
+ if (error.code === '23505') { // Unique violation
308
+ res.status(409).json({
309
+ success: false,
310
+ error: {
311
+ type: 'email_exists',
312
+ message: 'Email already registered',
313
+ hint: 'Try logging in instead, or use a different email.',
314
+ docs: 'https://webpeel.dev/docs/errors#email_exists',
315
+ },
316
+ requestId: crypto.randomUUID(),
317
+ });
318
+ return;
319
+ }
320
+ console.error('Registration error:', error);
321
+ res.status(500).json({
322
+ success: false,
323
+ error: {
324
+ type: 'registration_failed',
325
+ message: 'Failed to register user',
326
+ docs: 'https://webpeel.dev/docs/errors#registration_failed',
327
+ },
328
+ requestId: crypto.randomUUID(),
329
+ });
330
+ }
331
+ });
332
+ /**
333
+ * POST /v1/auth/login
334
+ * Login with email/password and get JWT token
335
+ */
336
+ router.post('/v1/auth/login', loginRateLimiter, async (req, res) => {
337
+ try {
338
+ const { email, password } = req.body;
339
+ if (!email || !password) {
340
+ res.status(400).json({
341
+ success: false,
342
+ error: {
343
+ type: 'missing_fields',
344
+ message: 'Email and password are required',
345
+ hint: 'Provide both email and password in the request body.',
346
+ docs: 'https://webpeel.dev/docs/errors#missing_fields',
347
+ },
348
+ requestId: crypto.randomUUID(),
349
+ });
350
+ return;
351
+ }
352
+ // Get user
353
+ const result = await pool.query('SELECT id, email, password_hash, tier FROM users WHERE email = $1', [email]);
354
+ // Constant-time auth: always run bcrypt.compare to prevent timing oracle
355
+ // (prevents user enumeration via response time differences)
356
+ const DUMMY_HASH = '$2b$12$LJ7F3mGTqKmEqFv5GsNXxeIkYwJwgJkOqSvKqGqKqGqKqGqKqGqKq';
357
+ const user = result.rows[0];
358
+ const hashToCompare = user?.password_hash ?? DUMMY_HASH;
359
+ const passwordValid = await bcrypt.compare(password, hashToCompare);
360
+ if (!user || !passwordValid) {
361
+ res.status(401).json({
362
+ success: false,
363
+ error: {
364
+ type: 'invalid_credentials',
365
+ message: 'Invalid email or password',
366
+ hint: 'Check your email and password and try again.',
367
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
368
+ },
369
+ requestId: crypto.randomUUID(),
370
+ });
371
+ return;
372
+ }
373
+ // Generate JWT
374
+ const jwtSecret = process.env.JWT_SECRET;
375
+ if (!jwtSecret) {
376
+ throw new Error('JWT_SECRET not configured');
377
+ }
378
+ const token = jwt.sign({
379
+ userId: user.id,
380
+ email: user.email,
381
+ tier: user.tier,
382
+ }, jwtSecret, { expiresIn: '7d' });
383
+ let refreshToken = null;
384
+ try {
385
+ refreshToken = await createRefreshToken(user.id, jwtSecret);
386
+ }
387
+ catch (refreshErr) {
388
+ console.error('Refresh token creation failed (login will continue without it):', refreshErr);
389
+ }
390
+ res.json({
391
+ token,
392
+ ...(refreshToken ? { refreshToken } : {}),
393
+ expiresIn: 604800,
394
+ user: {
395
+ id: user.id,
396
+ email: user.email,
397
+ tier: user.tier,
398
+ },
399
+ });
400
+ }
401
+ catch (error) {
402
+ console.error('Login error:', error);
403
+ res.status(500).json({
404
+ success: false,
405
+ error: {
406
+ type: 'login_failed',
407
+ message: 'Failed to login',
408
+ docs: 'https://webpeel.dev/docs/errors#login_failed',
409
+ },
410
+ requestId: crypto.randomUUID(),
411
+ });
412
+ }
413
+ });
414
+ /**
415
+ * POST /v1/auth/refresh
416
+ * Exchange a valid refresh token for a new access token + refresh token
417
+ */
418
+ router.post('/v1/auth/refresh', refreshRateLimiter, async (req, res) => {
419
+ try {
420
+ const { refreshToken } = req.body;
421
+ if (!refreshToken) {
422
+ res.status(400).json({
423
+ success: false,
424
+ error: {
425
+ type: 'missing_token',
426
+ message: 'refreshToken is required',
427
+ hint: 'Include the refreshToken from your previous login response.',
428
+ docs: 'https://webpeel.dev/docs/errors#missing_token',
429
+ },
430
+ requestId: crypto.randomUUID(),
431
+ });
432
+ return;
433
+ }
434
+ const jwtSecret = process.env.JWT_SECRET;
435
+ if (!jwtSecret) {
436
+ throw new Error('JWT_SECRET not configured');
437
+ }
438
+ // Verify JWT signature + expiry
439
+ let payload;
440
+ try {
441
+ payload = jwt.verify(refreshToken, jwtSecret);
442
+ }
443
+ catch {
444
+ res.status(401).json({
445
+ success: false,
446
+ error: {
447
+ type: 'invalid_token',
448
+ message: 'Invalid or expired refresh token',
449
+ hint: 'Log in again to get a new refresh token.',
450
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
451
+ },
452
+ requestId: crypto.randomUUID(),
453
+ });
454
+ return;
455
+ }
456
+ // Check token is not revoked and still exists
457
+ const tokenResult = await pool.query(`SELECT id, user_id, revoked_at FROM refresh_tokens WHERE id = $1`, [payload.jti]);
458
+ if (tokenResult.rows.length === 0 || tokenResult.rows[0].revoked_at !== null) {
459
+ res.status(401).json({
460
+ success: false,
461
+ error: {
462
+ type: 'token_revoked',
463
+ message: 'Refresh token has been revoked',
464
+ hint: 'Log in again to get a new refresh token.',
465
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
466
+ },
467
+ requestId: crypto.randomUUID(),
468
+ });
469
+ return;
470
+ }
471
+ // Get current user info (tier may have changed)
472
+ const userResult = await pool.query('SELECT id, email, tier FROM users WHERE id = $1', [payload.userId]);
473
+ if (userResult.rows.length === 0) {
474
+ res.status(401).json({
475
+ success: false,
476
+ error: {
477
+ type: 'user_not_found',
478
+ message: 'User no longer exists',
479
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
480
+ },
481
+ requestId: crypto.randomUUID(),
482
+ });
483
+ return;
484
+ }
485
+ const user = userResult.rows[0];
486
+ // Revoke old refresh token (rotate tokens)
487
+ await pool.query(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, [payload.jti]);
488
+ // Issue new access token (7d) + new refresh token (30d)
489
+ const newToken = jwt.sign({
490
+ userId: user.id,
491
+ email: user.email,
492
+ tier: user.tier,
493
+ }, jwtSecret, { expiresIn: '7d' });
494
+ const newRefreshToken = await createRefreshToken(user.id, jwtSecret);
495
+ res.json({
496
+ token: newToken,
497
+ refreshToken: newRefreshToken,
498
+ expiresIn: 604800,
499
+ });
500
+ }
501
+ catch (error) {
502
+ console.error('Refresh token error:', error);
503
+ res.status(500).json({
504
+ success: false,
505
+ error: {
506
+ type: 'refresh_failed',
507
+ message: 'Failed to refresh token',
508
+ docs: 'https://webpeel.dev/docs/errors#refresh_failed',
509
+ },
510
+ requestId: crypto.randomUUID(),
511
+ });
512
+ }
513
+ });
514
+ /**
515
+ * POST /v1/auth/revoke
516
+ * Revoke all refresh tokens for the current user (logout all devices)
517
+ */
518
+ router.post('/v1/auth/revoke', jwtAuth, async (req, res) => {
519
+ try {
520
+ const { userId } = req.user;
521
+ await pool.query(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, [userId]);
522
+ res.json({ success: true, message: 'All refresh tokens revoked' });
523
+ }
524
+ catch (error) {
525
+ console.error('Revoke tokens error:', error);
526
+ res.status(500).json({
527
+ success: false,
528
+ error: {
529
+ type: 'revoke_failed',
530
+ message: 'Failed to revoke tokens',
531
+ docs: 'https://webpeel.dev/docs/errors#revoke_failed',
532
+ },
533
+ requestId: crypto.randomUUID(),
534
+ });
535
+ }
536
+ });
537
+ /**
538
+ * POST /v1/auth/forgot-password
539
+ * Request a password reset email. Always returns success to prevent email enumeration.
540
+ */
541
+ router.post('/v1/auth/forgot-password', async (req, res) => {
542
+ const { email } = req.body;
543
+ // Always return success (prevent email enumeration)
544
+ res.json({ success: true, message: 'If an account exists, a reset link has been sent.' });
545
+ // Background: check if user exists, generate token, send email
546
+ if (!email || typeof email !== 'string')
547
+ return;
548
+ try {
549
+ const userResult = await pool.query('SELECT id, email FROM users WHERE email = $1', [email.toLowerCase().trim()]);
550
+ if (userResult.rows.length === 0)
551
+ return; // User doesn't exist — silently ignore
552
+ const user = userResult.rows[0];
553
+ // Generate a secure random token
554
+ const token = crypto.randomBytes(32).toString('hex');
555
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
556
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
557
+ // Invalidate any existing tokens for this user
558
+ await pool.query('UPDATE password_reset_tokens SET used = true WHERE user_id = $1 AND used = false', [user.id]);
559
+ // Store hashed token
560
+ await pool.query('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [user.id, tokenHash, expiresAt]);
561
+ // Send email
562
+ const resetUrl = `https://app.webpeel.dev/reset-password?token=${token}`;
563
+ await sendPasswordResetEmail(user.email, resetUrl);
564
+ }
565
+ catch (err) {
566
+ console.error('[auth] forgot-password error:', err);
567
+ }
568
+ });
569
+ /**
570
+ * POST /v1/auth/reset-password
571
+ * Reset a user's password using a valid reset token.
572
+ */
573
+ router.post('/v1/auth/reset-password', async (req, res) => {
574
+ const { token, password } = req.body;
575
+ if (!token || !password) {
576
+ return res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Token and password are required.' } });
577
+ }
578
+ if (password.length < 8) {
579
+ return res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Password must be at least 8 characters.' } });
580
+ }
581
+ try {
582
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
583
+ const result = await pool.query('SELECT id, user_id, expires_at, used FROM password_reset_tokens WHERE token_hash = $1', [tokenHash]);
584
+ if (result.rows.length === 0) {
585
+ return res.status(400).json({ success: false, error: { type: 'invalid_token', message: 'Invalid or expired reset link.' } });
586
+ }
587
+ const resetToken = result.rows[0];
588
+ if (resetToken.used) {
589
+ return res.status(400).json({ success: false, error: { type: 'token_used', message: 'This reset link has already been used.' } });
590
+ }
591
+ if (new Date(resetToken.expires_at) < new Date()) {
592
+ return res.status(400).json({ success: false, error: { type: 'token_expired', message: 'This reset link has expired. Please request a new one.' } });
593
+ }
594
+ // Hash new password using same method as registration
595
+ const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
596
+ // Update password
597
+ await pool.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [passwordHash, resetToken.user_id]);
598
+ // Mark token as used
599
+ await pool.query('UPDATE password_reset_tokens SET used = true WHERE id = $1', [resetToken.id]);
600
+ return res.json({ success: true, message: 'Password has been reset successfully.' });
601
+ }
602
+ catch (err) {
603
+ console.error('[auth] reset-password error:', err);
604
+ return res.status(500).json({ success: false, error: { type: 'server_error', message: 'Failed to reset password.' } });
605
+ }
606
+ });
607
+ /**
608
+ * GET /v1/me
609
+ * Get current user profile and usage
610
+ */
611
+ router.get('/v1/me', jwtAuth, async (req, res) => {
612
+ try {
613
+ const { userId } = req.user;
614
+ const result = await pool.query(`SELECT
615
+ u.id, u.email, u.tier, u.weekly_limit, u.burst_limit, u.rate_limit, u.created_at,
616
+ u.stripe_customer_id, u.stripe_subscription_id
617
+ FROM users u
618
+ WHERE u.id = $1`, [userId]);
619
+ if (result.rows.length === 0) {
620
+ res.status(404).json({
621
+ success: false,
622
+ error: {
623
+ type: 'user_not_found',
624
+ message: 'User not found',
625
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
626
+ },
627
+ requestId: crypto.randomUUID(),
628
+ });
629
+ return;
630
+ }
631
+ const user = result.rows[0];
632
+ res.json({
633
+ id: user.id,
634
+ email: user.email,
635
+ tier: user.tier,
636
+ weeklyLimit: user.weekly_limit,
637
+ burstLimit: user.burst_limit,
638
+ rateLimit: user.rate_limit,
639
+ createdAt: user.created_at,
640
+ hasStripe: !!user.stripe_customer_id,
641
+ });
642
+ }
643
+ catch (error) {
644
+ console.error('Get profile error:', error);
645
+ res.status(500).json({
646
+ success: false,
647
+ error: {
648
+ type: 'profile_failed',
649
+ message: 'Failed to get profile',
650
+ docs: 'https://webpeel.dev/docs/errors#profile_failed',
651
+ },
652
+ requestId: crypto.randomUUID(),
653
+ });
654
+ }
655
+ });
656
+ /**
657
+ * PATCH /v1/me
658
+ * Update current user's profile (name)
659
+ */
660
+ router.patch('/v1/me', jwtAuth, async (req, res) => {
661
+ try {
662
+ const { userId } = req.user;
663
+ const { name } = req.body;
664
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
665
+ res.status(400).json({
666
+ success: false,
667
+ error: {
668
+ type: 'name_required',
669
+ message: 'Name is required',
670
+ hint: 'Provide a non-empty "name" field in the request body.',
671
+ docs: 'https://webpeel.dev/docs/errors#name_required',
672
+ },
673
+ requestId: crypto.randomUUID(),
674
+ });
675
+ return;
676
+ }
677
+ if (name.length > 100) {
678
+ res.status(400).json({
679
+ success: false,
680
+ error: {
681
+ type: 'invalid_name',
682
+ message: 'Name must be 100 characters or less',
683
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
684
+ },
685
+ requestId: crypto.randomUUID(),
686
+ });
687
+ return;
688
+ }
689
+ const result = await pool.query('UPDATE users SET name = $1, updated_at = now() WHERE id = $2 RETURNING id, email, name, tier', [name.trim(), userId]);
690
+ if (result.rows.length === 0) {
691
+ res.status(404).json({
692
+ success: false,
693
+ error: {
694
+ type: 'user_not_found',
695
+ message: 'User not found',
696
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
697
+ },
698
+ requestId: crypto.randomUUID(),
699
+ });
700
+ return;
701
+ }
702
+ res.json({ user: result.rows[0] });
703
+ }
704
+ catch (error) {
705
+ console.error('Update me error:', error);
706
+ res.status(500).json({
707
+ success: false,
708
+ error: {
709
+ type: 'update_failed',
710
+ message: 'Failed to update profile',
711
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
712
+ },
713
+ requestId: crypto.randomUUID(),
714
+ });
715
+ }
716
+ });
717
+ /**
718
+ * Parse expiresIn parameter to a Date or null (null = never expires)
719
+ */
720
+ function parseExpiresIn(expiresIn) {
721
+ if (!expiresIn || expiresIn === 'never')
722
+ return null;
723
+ const now = new Date();
724
+ switch (expiresIn) {
725
+ case '7d': return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
726
+ case '30d': return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
727
+ case '90d': return new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
728
+ case '1y': return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
729
+ default: {
730
+ // Try ISO date string
731
+ const parsed = new Date(expiresIn);
732
+ if (!isNaN(parsed.getTime()) && parsed > now)
733
+ return parsed;
734
+ return null;
735
+ }
736
+ }
737
+ }
738
+ /**
739
+ * POST /v1/keys
740
+ * Create a new API key
741
+ */
742
+ router.post('/v1/keys', jwtAuth, async (req, res) => {
743
+ try {
744
+ const { userId } = req.user;
745
+ const { name, expiresIn, scope } = req.body;
746
+ // Validate scope — only allow known values; default to 'full'
747
+ const validScopes = ['full', 'read', 'restricted'];
748
+ const keyScope = validScopes.includes(scope) ? scope : 'full';
749
+ // Parse optional expiration
750
+ const expiresAt = parseExpiresIn(expiresIn);
751
+ // Generate API key
752
+ const apiKey = PostgresAuthStore.generateApiKey();
753
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
754
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
755
+ // Store API key
756
+ const result = await pool.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name, expires_at, scope)
757
+ VALUES ($1, $2, $3, $4, $5, $6)
758
+ RETURNING id, key_prefix, name, created_at, expires_at, scope`, [userId, keyHash, keyPrefix, name || 'Unnamed Key', expiresAt, keyScope]);
759
+ const key = result.rows[0];
760
+ res.status(201).json({
761
+ id: key.id,
762
+ key: apiKey, // SECURITY: Only returned once
763
+ prefix: key.key_prefix,
764
+ name: key.name,
765
+ scope: key.scope,
766
+ createdAt: key.created_at,
767
+ expiresAt: key.expires_at,
768
+ });
769
+ }
770
+ catch (error) {
771
+ console.error('Create key error:', error);
772
+ res.status(500).json({
773
+ success: false,
774
+ error: {
775
+ type: 'key_creation_failed',
776
+ message: 'Failed to create API key',
777
+ docs: 'https://webpeel.dev/docs/errors#key_creation_failed',
778
+ },
779
+ requestId: crypto.randomUUID(),
780
+ });
781
+ }
782
+ });
783
+ /**
784
+ * Format expiry as human-readable string
785
+ */
786
+ function formatExpiresIn(expiresAt) {
787
+ if (!expiresAt)
788
+ return null;
789
+ const now = new Date();
790
+ const diffMs = expiresAt.getTime() - now.getTime();
791
+ const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000));
792
+ if (diffDays < 0) {
793
+ const absDays = Math.abs(diffDays);
794
+ return absDays === 1 ? 'expired 1 day ago' : `expired ${absDays} days ago`;
795
+ }
796
+ if (diffDays === 0)
797
+ return 'expires today';
798
+ if (diffDays === 1)
799
+ return 'in 1 day';
800
+ return `in ${diffDays} days`;
801
+ }
802
+ /**
803
+ * GET /v1/keys
804
+ * List user's API keys (prefix only, never full key)
805
+ */
806
+ router.get('/v1/keys', jwtAuth, async (req, res) => {
807
+ try {
808
+ const { userId } = req.user;
809
+ const result = await pool.query(`SELECT id, key_prefix, name, is_active, created_at, last_used_at, expires_at, scope
810
+ FROM api_keys
811
+ WHERE user_id = $1
812
+ ORDER BY created_at DESC`, [userId]);
813
+ const now = new Date();
814
+ res.json({
815
+ keys: result.rows.map(key => {
816
+ const expiresAt = key.expires_at ? new Date(key.expires_at) : null;
817
+ const isExpired = expiresAt !== null && expiresAt <= now;
818
+ return {
819
+ id: key.id,
820
+ prefix: key.key_prefix,
821
+ name: key.name,
822
+ isActive: key.is_active,
823
+ scope: key.scope || 'full',
824
+ createdAt: key.created_at,
825
+ lastUsedAt: key.last_used_at,
826
+ expiresAt: key.expires_at,
827
+ isExpired,
828
+ expiresIn: formatExpiresIn(expiresAt),
829
+ };
830
+ }),
831
+ });
832
+ }
833
+ catch (error) {
834
+ console.error('List keys error:', error);
835
+ res.status(500).json({
836
+ success: false,
837
+ error: {
838
+ type: 'list_keys_failed',
839
+ message: 'Failed to list API keys',
840
+ docs: 'https://webpeel.dev/docs/errors#list_keys_failed',
841
+ },
842
+ requestId: crypto.randomUUID(),
843
+ });
844
+ }
845
+ });
846
+ /**
847
+ * PATCH /v1/keys/:id
848
+ * Update an API key (currently: name only)
849
+ */
850
+ router.patch('/v1/keys/:id', jwtAuth, async (req, res) => {
851
+ try {
852
+ const { userId } = req.user;
853
+ const { id } = req.params;
854
+ const { name } = req.body;
855
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
856
+ res.status(400).json({
857
+ success: false,
858
+ error: {
859
+ type: 'invalid_name',
860
+ message: 'Key name is required',
861
+ hint: 'Provide a non-empty "name" field in the request body.',
862
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
863
+ },
864
+ requestId: crypto.randomUUID(),
865
+ });
866
+ return;
867
+ }
868
+ if (name.length > 64) {
869
+ res.status(400).json({
870
+ success: false,
871
+ error: {
872
+ type: 'invalid_name',
873
+ message: 'Key name must be 64 characters or less',
874
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
875
+ },
876
+ requestId: crypto.randomUUID(),
877
+ });
878
+ return;
879
+ }
880
+ const result = await pool.query(`UPDATE api_keys SET name = $1 WHERE id = $2 AND user_id = $3 RETURNING id, name, key_prefix, created_at, last_used_at, is_active, expires_at`, [name.trim(), id, userId]);
881
+ if (result.rowCount === 0) {
882
+ res.status(404).json({
883
+ success: false,
884
+ error: {
885
+ type: 'not_found',
886
+ message: 'API key not found',
887
+ docs: 'https://webpeel.dev/docs/errors#not_found',
888
+ },
889
+ requestId: crypto.randomUUID(),
890
+ });
891
+ return;
892
+ }
893
+ const key = result.rows[0];
894
+ res.json({
895
+ id: key.id,
896
+ name: key.name,
897
+ prefix: key.key_prefix,
898
+ createdAt: key.created_at,
899
+ lastUsedAt: key.last_used_at,
900
+ isActive: key.is_active,
901
+ expiresAt: key.expires_at,
902
+ });
903
+ }
904
+ catch (error) {
905
+ console.error('Update key error:', error);
906
+ res.status(500).json({
907
+ success: false,
908
+ error: {
909
+ type: 'update_failed',
910
+ message: 'Failed to update API key',
911
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
912
+ },
913
+ requestId: crypto.randomUUID(),
914
+ });
915
+ }
916
+ });
917
+ /**
918
+ * DELETE /v1/keys/:id
919
+ * Deactivate an API key
920
+ */
921
+ router.delete('/v1/keys/:id', jwtAuth, async (req, res) => {
922
+ try {
923
+ const { userId } = req.user;
924
+ const { id } = req.params;
925
+ // Verify ownership and deactivate
926
+ const result = await pool.query(`UPDATE api_keys
927
+ SET is_active = false
928
+ WHERE id = $1 AND user_id = $2
929
+ RETURNING id`, [id, userId]);
930
+ if (result.rows.length === 0) {
931
+ res.status(404).json({
932
+ success: false,
933
+ error: {
934
+ type: 'key_not_found',
935
+ message: 'API key not found or access denied',
936
+ docs: 'https://webpeel.dev/docs/errors#not_found',
937
+ },
938
+ requestId: crypto.randomUUID(),
939
+ });
940
+ return;
941
+ }
942
+ res.json({
943
+ success: true,
944
+ message: 'API key deactivated',
945
+ });
946
+ }
947
+ catch (error) {
948
+ console.error('Delete key error:', error);
949
+ res.status(500).json({
950
+ success: false,
951
+ error: {
952
+ type: 'delete_key_failed',
953
+ message: 'Failed to delete API key',
954
+ docs: 'https://webpeel.dev/docs/errors#delete_key_failed',
955
+ },
956
+ requestId: crypto.randomUUID(),
957
+ });
958
+ }
959
+ });
960
+ /**
961
+ * GET /v1/usage
962
+ * Get current week usage + limits + burst + extra usage
963
+ */
964
+ router.get('/v1/usage', async (req, res) => {
965
+ try {
966
+ // Accept both JWT session tokens and API keys
967
+ const userId = req.user?.userId || req.auth?.keyInfo?.accountId;
968
+ if (!userId) {
969
+ // Fall back to jwtAuth behavior for informative error
970
+ res.status(401).json({
971
+ success: false,
972
+ error: {
973
+ type: 'unauthorized',
974
+ message: 'Authentication required. Provide a JWT token or API key.',
975
+ hint: 'Get a free API key at https://app.webpeel.dev/keys',
976
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
977
+ },
978
+ requestId: crypto.randomUUID(),
979
+ });
980
+ return;
981
+ }
982
+ // Helper: Get current ISO week
983
+ const getCurrentWeek = () => {
984
+ const now = new Date();
985
+ const year = now.getUTCFullYear();
986
+ const jan4 = new Date(Date.UTC(year, 0, 4));
987
+ const weekNum = Math.ceil(((now.getTime() - jan4.getTime()) / 86400000 + jan4.getUTCDay() + 1) / 7);
988
+ return `${year}-W${String(weekNum).padStart(2, '0')}`;
989
+ };
990
+ // Helper: Get current hour bucket
991
+ const getCurrentHour = () => {
992
+ return new Date().toISOString().substring(0, 13);
993
+ };
994
+ // Helper: Get week reset time
995
+ const getWeekResetTime = () => {
996
+ const now = new Date();
997
+ const dayOfWeek = now.getUTCDay();
998
+ const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
999
+ const nextMonday = new Date(now);
1000
+ nextMonday.setUTCDate(now.getUTCDate() + daysUntilMonday);
1001
+ nextMonday.setUTCHours(0, 0, 0, 0);
1002
+ return nextMonday.toISOString();
1003
+ };
1004
+ // Helper: Get time until next hour
1005
+ const getTimeUntilNextHour = () => {
1006
+ const now = new Date();
1007
+ const minutesRemaining = 59 - now.getUTCMinutes();
1008
+ if (minutesRemaining === 0)
1009
+ return '< 1 min';
1010
+ return `${minutesRemaining} min`;
1011
+ };
1012
+ // Helper: Get next month reset
1013
+ const getMonthResetTime = () => {
1014
+ const now = new Date();
1015
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)).toISOString();
1016
+ };
1017
+ const currentWeek = getCurrentWeek();
1018
+ const currentHour = getCurrentHour();
1019
+ // Get user plan info
1020
+ const planResult = await pool.query(`SELECT tier, weekly_limit, burst_limit FROM users WHERE id = $1`, [userId]);
1021
+ if (planResult.rows.length === 0) {
1022
+ res.status(404).json({
1023
+ success: false,
1024
+ error: {
1025
+ type: 'user_not_found',
1026
+ message: 'User not found',
1027
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1028
+ },
1029
+ requestId: crypto.randomUUID(),
1030
+ });
1031
+ return;
1032
+ }
1033
+ const plan = planResult.rows[0];
1034
+ // Get weekly usage
1035
+ const weeklyResult = await pool.query(`SELECT
1036
+ COALESCE(SUM(wu.basic_count), 0) as basic_used,
1037
+ COALESCE(SUM(wu.stealth_count), 0) as stealth_used,
1038
+ COALESCE(SUM(wu.captcha_count), 0) as captcha_used,
1039
+ COALESCE(SUM(wu.search_count), 0) as search_used,
1040
+ COALESCE(SUM(wu.total_count), 0) as total_used,
1041
+ COALESCE(MAX(wu.rollover_credits), 0) as rollover_credits
1042
+ FROM users u
1043
+ LEFT JOIN api_keys ak ON ak.user_id = u.id
1044
+ LEFT JOIN weekly_usage wu ON wu.api_key_id = ak.id AND wu.week = $2
1045
+ WHERE u.id = $1
1046
+ GROUP BY u.id`, [userId, currentWeek]);
1047
+ let weeklyUsage = weeklyResult.rows[0] || {
1048
+ basic_used: 0,
1049
+ stealth_used: 0,
1050
+ captcha_used: 0,
1051
+ search_used: 0,
1052
+ total_used: 0,
1053
+ rollover_credits: 0,
1054
+ };
1055
+ // Fallback: if weekly_usage is 0 but usage_logs has entries (e.g. playground/JWT auth),
1056
+ // count from usage_logs for the current week so the counter reflects real activity
1057
+ if (parseInt(weeklyUsage.total_used) === 0) {
1058
+ const weekStart = currentWeek; // e.g. "2026-W10"
1059
+ const [weekYear, weekNum] = weekStart.split('-W').map(Number);
1060
+ // Compute the start of the week (Monday) from ISO week number
1061
+ const jan4 = new Date(weekYear, 0, 4);
1062
+ const dayOfWeek = jan4.getDay() || 7;
1063
+ const weekStartDate = new Date(jan4);
1064
+ weekStartDate.setDate(jan4.getDate() - (dayOfWeek - 1) + (weekNum - 1) * 7);
1065
+ weekStartDate.setHours(0, 0, 0, 0);
1066
+ const logResult = await pool.query(`SELECT COUNT(*) as total_used FROM usage_logs WHERE user_id = $1 AND created_at >= $2`, [userId, weekStartDate.toISOString()]).catch(() => ({ rows: [{ total_used: 0 }] }));
1067
+ const logCount = parseInt(logResult.rows[0]?.total_used) || 0;
1068
+ if (logCount > 0) {
1069
+ weeklyUsage = { ...weeklyUsage, total_used: logCount, basic_used: logCount };
1070
+ }
1071
+ }
1072
+ const totalAvailable = plan.weekly_limit + weeklyUsage.rollover_credits;
1073
+ const remaining = Math.max(0, totalAvailable - weeklyUsage.total_used);
1074
+ const percentUsed = totalAvailable > 0 ? Math.round((weeklyUsage.total_used / totalAvailable) * 100) : 0;
1075
+ // Get burst usage (current hour)
1076
+ const burstResult = await pool.query(`SELECT COALESCE(SUM(bu.count), 0) as burst_used
1077
+ FROM users u
1078
+ LEFT JOIN api_keys ak ON ak.user_id = u.id
1079
+ LEFT JOIN burst_usage bu ON bu.api_key_id = ak.id AND bu.hour_bucket = $2
1080
+ WHERE u.id = $1`, [userId, currentHour]);
1081
+ const burstUsed = burstResult.rows[0]?.burst_used || 0;
1082
+ const burstPercent = plan.burst_limit > 0 ? Math.round((burstUsed / plan.burst_limit) * 100) : 0;
1083
+ // Get extra usage info
1084
+ const extraResult = await pool.query(`SELECT
1085
+ extra_usage_enabled,
1086
+ extra_usage_balance,
1087
+ extra_usage_spent,
1088
+ extra_usage_spending_limit,
1089
+ auto_reload_enabled
1090
+ FROM users
1091
+ WHERE id = $1`, [userId]);
1092
+ const extra = extraResult.rows[0];
1093
+ const extraPercent = extra.extra_usage_spending_limit > 0
1094
+ ? Math.round((parseFloat(extra.extra_usage_spent) / parseFloat(extra.extra_usage_spending_limit)) * 100)
1095
+ : 0;
1096
+ res.json({
1097
+ plan: {
1098
+ tier: plan.tier,
1099
+ weeklyLimit: plan.weekly_limit,
1100
+ burstLimit: plan.burst_limit,
1101
+ },
1102
+ session: {
1103
+ burstUsed,
1104
+ burstLimit: plan.burst_limit,
1105
+ resetsIn: getTimeUntilNextHour(),
1106
+ percentUsed: burstPercent,
1107
+ },
1108
+ weekly: {
1109
+ week: currentWeek,
1110
+ basicUsed: weeklyUsage.basic_used,
1111
+ stealthUsed: weeklyUsage.stealth_used,
1112
+ captchaUsed: weeklyUsage.captcha_used,
1113
+ searchUsed: weeklyUsage.search_used,
1114
+ totalUsed: weeklyUsage.total_used,
1115
+ totalAvailable,
1116
+ rolloverCredits: weeklyUsage.rollover_credits,
1117
+ remaining,
1118
+ percentUsed,
1119
+ resetsAt: getWeekResetTime(),
1120
+ },
1121
+ extraUsage: {
1122
+ enabled: extra.extra_usage_enabled,
1123
+ spent: parseFloat(extra.extra_usage_spent),
1124
+ spendingLimit: parseFloat(extra.extra_usage_spending_limit),
1125
+ balance: parseFloat(extra.extra_usage_balance),
1126
+ autoReload: extra.auto_reload_enabled,
1127
+ percentUsed: extraPercent,
1128
+ resetsAt: getMonthResetTime(),
1129
+ },
1130
+ });
1131
+ }
1132
+ catch (error) {
1133
+ console.error('Get usage error:', error);
1134
+ res.status(500).json({
1135
+ success: false,
1136
+ error: {
1137
+ type: 'usage_failed',
1138
+ message: 'Failed to get usage',
1139
+ docs: 'https://webpeel.dev/docs/errors#usage_failed',
1140
+ },
1141
+ requestId: crypto.randomUUID(),
1142
+ });
1143
+ }
1144
+ });
1145
+ /**
1146
+ * GET /v1/usage/history
1147
+ * Get daily usage history for the past N days (default 7)
1148
+ */
1149
+ router.get('/v1/usage/history', async (req, res) => {
1150
+ try {
1151
+ const userId = req.user?.userId || req.auth?.keyInfo?.accountId;
1152
+ if (!userId) {
1153
+ res.status(401).json({
1154
+ success: false,
1155
+ error: {
1156
+ type: 'unauthorized',
1157
+ message: 'Authentication required.',
1158
+ hint: 'Get a free API key at https://app.webpeel.dev/keys',
1159
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1160
+ },
1161
+ requestId: crypto.randomUUID(),
1162
+ });
1163
+ return;
1164
+ }
1165
+ const days = Math.min(Math.max(parseInt(req.query.days) || 7, 1), 90);
1166
+ // Get daily usage from usage_logs table
1167
+ const result = await pool.query(`SELECT
1168
+ DATE(created_at) as date,
1169
+ COUNT(*) FILTER (WHERE method = 'basic' OR method IS NULL) as fetches,
1170
+ COUNT(*) FILTER (WHERE method = 'stealth') as stealth,
1171
+ COUNT(*) FILTER (WHERE method = 'search') as search
1172
+ FROM usage_logs
1173
+ WHERE user_id = $1
1174
+ AND created_at >= NOW() - INTERVAL '1 day' * $2
1175
+ GROUP BY DATE(created_at)
1176
+ ORDER BY date ASC`, [userId, days]);
1177
+ // Fill in missing days with zeros
1178
+ const history = [];
1179
+ const now = new Date();
1180
+ for (let i = days - 1; i >= 0; i--) {
1181
+ const d = new Date(now);
1182
+ d.setUTCDate(d.getUTCDate() - i);
1183
+ const dateStr = d.toISOString().substring(0, 10);
1184
+ const row = result.rows.find((r) => r.date?.toISOString?.().substring(0, 10) === dateStr || r.date === dateStr);
1185
+ history.push({
1186
+ date: dateStr,
1187
+ fetches: parseInt(row?.fetches || '0', 10),
1188
+ stealth: parseInt(row?.stealth || '0', 10),
1189
+ search: parseInt(row?.search || '0', 10),
1190
+ });
1191
+ }
1192
+ res.json({ history });
1193
+ }
1194
+ catch (error) {
1195
+ console.error('Get usage history error:', error);
1196
+ res.status(500).json({
1197
+ success: false,
1198
+ error: {
1199
+ type: 'history_failed',
1200
+ message: 'Failed to get usage history',
1201
+ docs: 'https://webpeel.dev/docs/errors#history_failed',
1202
+ },
1203
+ requestId: crypto.randomUUID(),
1204
+ });
1205
+ }
1206
+ });
1207
+ /**
1208
+ * POST /v1/extra-usage/toggle
1209
+ * Enable/disable extra usage
1210
+ */
1211
+ router.post('/v1/extra-usage/toggle', jwtAuth, async (req, res) => {
1212
+ try {
1213
+ const { userId } = req.user;
1214
+ const { enabled } = req.body;
1215
+ if (typeof enabled !== 'boolean') {
1216
+ res.status(400).json({
1217
+ success: false,
1218
+ error: {
1219
+ type: 'invalid_request',
1220
+ message: 'enabled must be a boolean',
1221
+ hint: 'Pass enabled: true or enabled: false in the request body.',
1222
+ docs: 'https://webpeel.dev/docs/errors#invalid_request',
1223
+ },
1224
+ requestId: crypto.randomUUID(),
1225
+ });
1226
+ return;
1227
+ }
1228
+ await pool.query('UPDATE users SET extra_usage_enabled = $1, updated_at = now() WHERE id = $2', [enabled, userId]);
1229
+ res.json({
1230
+ success: true,
1231
+ enabled,
1232
+ });
1233
+ }
1234
+ catch (error) {
1235
+ console.error('Toggle extra usage error:', error);
1236
+ res.status(500).json({
1237
+ success: false,
1238
+ error: {
1239
+ type: 'toggle_failed',
1240
+ message: 'Failed to toggle extra usage',
1241
+ docs: 'https://webpeel.dev/docs/errors#toggle_failed',
1242
+ },
1243
+ requestId: crypto.randomUUID(),
1244
+ });
1245
+ }
1246
+ });
1247
+ /**
1248
+ * POST /v1/extra-usage/limit
1249
+ * Adjust spending limit
1250
+ */
1251
+ router.post('/v1/extra-usage/limit', jwtAuth, async (req, res) => {
1252
+ try {
1253
+ const { userId } = req.user;
1254
+ const { limit } = req.body;
1255
+ if (typeof limit !== 'number' || limit < 10 || limit > 500) {
1256
+ res.status(400).json({
1257
+ success: false,
1258
+ error: {
1259
+ type: 'invalid_limit',
1260
+ message: 'Limit must be a number between 10 and 500',
1261
+ hint: 'Pass a numeric limit between 10 and 500 in the request body.',
1262
+ docs: 'https://webpeel.dev/docs/errors#invalid_limit',
1263
+ },
1264
+ requestId: crypto.randomUUID(),
1265
+ });
1266
+ return;
1267
+ }
1268
+ await pool.query('UPDATE users SET extra_usage_spending_limit = $1, updated_at = now() WHERE id = $2', [limit, userId]);
1269
+ res.json({
1270
+ success: true,
1271
+ limit,
1272
+ });
1273
+ }
1274
+ catch (error) {
1275
+ console.error('Set limit error:', error);
1276
+ res.status(500).json({
1277
+ success: false,
1278
+ error: {
1279
+ type: 'limit_failed',
1280
+ message: 'Failed to set spending limit',
1281
+ docs: 'https://webpeel.dev/docs/errors#limit_failed',
1282
+ },
1283
+ requestId: crypto.randomUUID(),
1284
+ });
1285
+ }
1286
+ });
1287
+ /**
1288
+ * POST /v1/extra-usage/buy
1289
+ * Add to extra usage balance (future: Stripe checkout)
1290
+ */
1291
+ router.post('/v1/extra-usage/buy', jwtAuth, async (_req, res) => {
1292
+ // DISABLED: Stripe integration in progress
1293
+ res.status(501).json({
1294
+ success: false,
1295
+ error: {
1296
+ type: 'not_implemented',
1297
+ message: 'Extra usage purchases are available through our billing portal. Visit https://app.webpeel.dev/billing',
1298
+ hint: 'Visit https://app.webpeel.dev/billing to manage your usage.',
1299
+ docs: 'https://webpeel.dev/docs/errors#not_implemented',
1300
+ },
1301
+ requestId: crypto.randomUUID(),
1302
+ });
1303
+ });
1304
+ /**
1305
+ * PATCH /v1/user/profile
1306
+ * Update user profile (name, avatar)
1307
+ */
1308
+ router.patch('/v1/user/profile', jwtAuth, async (req, res) => {
1309
+ try {
1310
+ const { userId } = req.user;
1311
+ const { name, avatarUrl } = req.body;
1312
+ // Validate inputs
1313
+ if (name && typeof name !== 'string') {
1314
+ res.status(400).json({
1315
+ success: false,
1316
+ error: {
1317
+ type: 'invalid_name',
1318
+ message: 'Name must be a string',
1319
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
1320
+ },
1321
+ requestId: crypto.randomUUID(),
1322
+ });
1323
+ return;
1324
+ }
1325
+ if (name && name.length > 100) {
1326
+ res.status(400).json({
1327
+ success: false,
1328
+ error: {
1329
+ type: 'invalid_name',
1330
+ message: 'Name too long (max 100 characters)',
1331
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
1332
+ },
1333
+ requestId: crypto.randomUUID(),
1334
+ });
1335
+ return;
1336
+ }
1337
+ if (avatarUrl && typeof avatarUrl !== 'string') {
1338
+ res.status(400).json({
1339
+ success: false,
1340
+ error: {
1341
+ type: 'invalid_avatar',
1342
+ message: 'Avatar URL must be a string',
1343
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1344
+ },
1345
+ requestId: crypto.randomUUID(),
1346
+ });
1347
+ return;
1348
+ }
1349
+ if (avatarUrl && avatarUrl.length > 500) {
1350
+ res.status(400).json({
1351
+ success: false,
1352
+ error: {
1353
+ type: 'invalid_avatar',
1354
+ message: 'Avatar URL too long (max 500 characters)',
1355
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1356
+ },
1357
+ requestId: crypto.randomUUID(),
1358
+ });
1359
+ return;
1360
+ }
1361
+ if (avatarUrl) {
1362
+ try {
1363
+ const parsed = new URL(avatarUrl);
1364
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
1365
+ res.status(400).json({
1366
+ success: false,
1367
+ error: {
1368
+ type: 'invalid_avatar',
1369
+ message: 'Avatar URL must use http or https protocol',
1370
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1371
+ },
1372
+ requestId: crypto.randomUUID(),
1373
+ });
1374
+ return;
1375
+ }
1376
+ }
1377
+ catch {
1378
+ res.status(400).json({
1379
+ success: false,
1380
+ error: {
1381
+ type: 'invalid_avatar',
1382
+ message: 'Avatar URL must be a valid URL',
1383
+ hint: 'Provide a fully-qualified URL starting with https://',
1384
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1385
+ },
1386
+ requestId: crypto.randomUUID(),
1387
+ });
1388
+ return;
1389
+ }
1390
+ }
1391
+ // Build update query dynamically
1392
+ const updates = [];
1393
+ const values = [];
1394
+ let paramIndex = 1;
1395
+ if (name !== undefined) {
1396
+ updates.push(`name = $${paramIndex++}`);
1397
+ values.push(name);
1398
+ }
1399
+ if (avatarUrl !== undefined) {
1400
+ updates.push(`avatar_url = $${paramIndex++}`);
1401
+ values.push(avatarUrl);
1402
+ }
1403
+ if (updates.length === 0) {
1404
+ res.status(400).json({
1405
+ success: false,
1406
+ error: {
1407
+ type: 'no_updates',
1408
+ message: 'No fields to update',
1409
+ hint: 'Provide at least one of: name, avatarUrl.',
1410
+ docs: 'https://webpeel.dev/docs/errors#no_updates',
1411
+ },
1412
+ requestId: crypto.randomUUID(),
1413
+ });
1414
+ return;
1415
+ }
1416
+ updates.push(`updated_at = now()`);
1417
+ values.push(userId);
1418
+ const result = await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, email, name, avatar_url`, values);
1419
+ if (result.rows.length === 0) {
1420
+ res.status(404).json({
1421
+ success: false,
1422
+ error: {
1423
+ type: 'user_not_found',
1424
+ message: 'User not found',
1425
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1426
+ },
1427
+ requestId: crypto.randomUUID(),
1428
+ });
1429
+ return;
1430
+ }
1431
+ res.json({
1432
+ success: true,
1433
+ user: {
1434
+ id: result.rows[0].id,
1435
+ email: result.rows[0].email,
1436
+ name: result.rows[0].name,
1437
+ avatar: result.rows[0].avatar_url,
1438
+ },
1439
+ });
1440
+ }
1441
+ catch (error) {
1442
+ console.error('Update profile error:', error);
1443
+ res.status(500).json({
1444
+ success: false,
1445
+ error: {
1446
+ type: 'update_failed',
1447
+ message: 'Failed to update profile',
1448
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
1449
+ },
1450
+ requestId: crypto.randomUUID(),
1451
+ });
1452
+ }
1453
+ });
1454
+ /**
1455
+ * PATCH /v1/user/password
1456
+ * Change password (verify current, hash new)
1457
+ */
1458
+ router.patch('/v1/user/password', jwtAuth, async (req, res) => {
1459
+ try {
1460
+ const { userId } = req.user;
1461
+ const { currentPassword, newPassword } = req.body;
1462
+ if (!currentPassword || !newPassword) {
1463
+ res.status(400).json({
1464
+ success: false,
1465
+ error: {
1466
+ type: 'missing_fields',
1467
+ message: 'Current and new passwords are required',
1468
+ hint: 'Provide both currentPassword and newPassword in the request body.',
1469
+ docs: 'https://webpeel.dev/docs/errors#missing_fields',
1470
+ },
1471
+ requestId: crypto.randomUUID(),
1472
+ });
1473
+ return;
1474
+ }
1475
+ if (!isValidPassword(newPassword)) {
1476
+ res.status(400).json({
1477
+ success: false,
1478
+ error: {
1479
+ type: 'weak_password',
1480
+ message: 'Password must be at least 8 characters',
1481
+ hint: 'Choose a password with at least 8 characters.',
1482
+ docs: 'https://webpeel.dev/docs/errors#weak_password',
1483
+ },
1484
+ requestId: crypto.randomUUID(),
1485
+ });
1486
+ return;
1487
+ }
1488
+ // Get current password hash
1489
+ const userResult = await pool.query('SELECT password_hash FROM users WHERE id = $1', [userId]);
1490
+ if (userResult.rows.length === 0) {
1491
+ res.status(404).json({
1492
+ success: false,
1493
+ error: {
1494
+ type: 'user_not_found',
1495
+ message: 'User not found',
1496
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1497
+ },
1498
+ requestId: crypto.randomUUID(),
1499
+ });
1500
+ return;
1501
+ }
1502
+ // OAuth users don't have passwords
1503
+ if (!userResult.rows[0].password_hash) {
1504
+ res.status(400).json({
1505
+ success: false,
1506
+ error: {
1507
+ type: 'oauth_user',
1508
+ message: 'OAuth users cannot set passwords. Please use your OAuth provider to manage your account.',
1509
+ hint: 'Manage your account through your OAuth provider (e.g. Google, GitHub).',
1510
+ docs: 'https://webpeel.dev/docs/errors#oauth_user',
1511
+ },
1512
+ requestId: crypto.randomUUID(),
1513
+ });
1514
+ return;
1515
+ }
1516
+ // Verify current password
1517
+ const passwordValid = await bcrypt.compare(currentPassword, userResult.rows[0].password_hash);
1518
+ if (!passwordValid) {
1519
+ res.status(401).json({
1520
+ success: false,
1521
+ error: {
1522
+ type: 'invalid_password',
1523
+ message: 'Current password is incorrect',
1524
+ hint: 'Double-check your current password and try again.',
1525
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1526
+ },
1527
+ requestId: crypto.randomUUID(),
1528
+ });
1529
+ return;
1530
+ }
1531
+ // Hash new password
1532
+ const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
1533
+ // Update password
1534
+ await pool.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [newPasswordHash, userId]);
1535
+ res.json({ success: true, message: 'Password updated successfully' });
1536
+ }
1537
+ catch (error) {
1538
+ console.error('Change password error:', error);
1539
+ res.status(500).json({
1540
+ success: false,
1541
+ error: {
1542
+ type: 'update_failed',
1543
+ message: 'Failed to change password',
1544
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
1545
+ },
1546
+ requestId: crypto.randomUUID(),
1547
+ });
1548
+ }
1549
+ });
1550
+ /**
1551
+ * DELETE /v1/user/account
1552
+ * Delete account + cascade to api_keys, oauth_accounts
1553
+ */
1554
+ router.delete('/v1/user/account', jwtAuth, async (req, res) => {
1555
+ try {
1556
+ const { userId } = req.user;
1557
+ const { password, confirmEmail } = req.body;
1558
+ // Get user info
1559
+ const userResult = await pool.query('SELECT email, password_hash FROM users WHERE id = $1', [userId]);
1560
+ if (userResult.rows.length === 0) {
1561
+ res.status(404).json({
1562
+ success: false,
1563
+ error: {
1564
+ type: 'user_not_found',
1565
+ message: 'User not found',
1566
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1567
+ },
1568
+ requestId: crypto.randomUUID(),
1569
+ });
1570
+ return;
1571
+ }
1572
+ const user = userResult.rows[0];
1573
+ // Verify email confirmation
1574
+ if (confirmEmail !== user.email) {
1575
+ res.status(400).json({
1576
+ success: false,
1577
+ error: {
1578
+ type: 'email_mismatch',
1579
+ message: 'Email confirmation does not match account email',
1580
+ hint: 'Provide your exact account email in the confirmEmail field.',
1581
+ docs: 'https://webpeel.dev/docs/errors#email_mismatch',
1582
+ },
1583
+ requestId: crypto.randomUUID(),
1584
+ });
1585
+ return;
1586
+ }
1587
+ // Verify password (if user has one - OAuth users might not)
1588
+ if (user.password_hash) {
1589
+ if (!password) {
1590
+ res.status(400).json({
1591
+ success: false,
1592
+ error: {
1593
+ type: 'missing_password',
1594
+ message: 'Password is required',
1595
+ hint: 'Provide your account password to confirm account deletion.',
1596
+ docs: 'https://webpeel.dev/docs/errors#missing_password',
1597
+ },
1598
+ requestId: crypto.randomUUID(),
1599
+ });
1600
+ return;
1601
+ }
1602
+ const passwordValid = await bcrypt.compare(password, user.password_hash);
1603
+ if (!passwordValid) {
1604
+ res.status(401).json({
1605
+ success: false,
1606
+ error: {
1607
+ type: 'invalid_password',
1608
+ message: 'Password is incorrect',
1609
+ hint: 'Double-check your password and try again.',
1610
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1611
+ },
1612
+ requestId: crypto.randomUUID(),
1613
+ });
1614
+ return;
1615
+ }
1616
+ }
1617
+ // Delete user and all related data in a transaction
1618
+ const client = await pool.connect();
1619
+ try {
1620
+ await client.query('BEGIN');
1621
+ await client.query('DELETE FROM api_keys WHERE user_id = $1', [userId]);
1622
+ await client.query('DELETE FROM oauth_accounts WHERE user_id = $1', [userId]);
1623
+ await client.query('DELETE FROM users WHERE id = $1', [userId]);
1624
+ await client.query('COMMIT');
1625
+ }
1626
+ catch (txError) {
1627
+ await client.query('ROLLBACK');
1628
+ throw txError;
1629
+ }
1630
+ finally {
1631
+ client.release();
1632
+ }
1633
+ res.json({
1634
+ success: true,
1635
+ message: 'Account deleted successfully. We\'re sorry to see you go!'
1636
+ });
1637
+ }
1638
+ catch (error) {
1639
+ console.error('Delete account error:', error);
1640
+ res.status(500).json({
1641
+ success: false,
1642
+ error: {
1643
+ type: 'delete_failed',
1644
+ message: 'Failed to delete account',
1645
+ docs: 'https://webpeel.dev/docs/errors#delete_failed',
1646
+ },
1647
+ requestId: crypto.randomUUID(),
1648
+ });
1649
+ }
1650
+ });
1651
+ /**
1652
+ * GET /v1/user/alert-preferences
1653
+ * Returns current alert threshold and email
1654
+ */
1655
+ router.get('/v1/user/alert-preferences', jwtAuth, async (req, res) => {
1656
+ try {
1657
+ const { userId } = req.user;
1658
+ const result = await pool.query('SELECT alert_threshold, alert_email FROM users WHERE id = $1', [userId]);
1659
+ if (result.rows.length === 0) {
1660
+ res.status(404).json({
1661
+ success: false,
1662
+ error: {
1663
+ type: 'user_not_found',
1664
+ message: 'User not found',
1665
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1666
+ },
1667
+ requestId: crypto.randomUUID(),
1668
+ });
1669
+ return;
1670
+ }
1671
+ res.json({
1672
+ threshold: result.rows[0].alert_threshold,
1673
+ email: result.rows[0].alert_email,
1674
+ });
1675
+ }
1676
+ catch (error) {
1677
+ console.error('Get alert preferences error:', error);
1678
+ res.status(500).json({
1679
+ success: false,
1680
+ error: {
1681
+ type: 'get_prefs_failed',
1682
+ message: 'Failed to get alert preferences',
1683
+ docs: 'https://webpeel.dev/docs/errors#get_prefs_failed',
1684
+ },
1685
+ requestId: crypto.randomUUID(),
1686
+ });
1687
+ }
1688
+ });
1689
+ /**
1690
+ * PUT /v1/user/alert-preferences
1691
+ * Save alert threshold and/or email
1692
+ * Body: { threshold: number | null, email: string | null }
1693
+ */
1694
+ router.put('/v1/user/alert-preferences', jwtAuth, async (req, res) => {
1695
+ try {
1696
+ const { userId } = req.user;
1697
+ const { threshold, email } = req.body;
1698
+ // Validate threshold: must be null or a number between 1 and 100
1699
+ if (threshold !== null && threshold !== undefined) {
1700
+ if (typeof threshold !== 'number' || threshold < 1 || threshold > 100) {
1701
+ res.status(400).json({
1702
+ success: false,
1703
+ error: {
1704
+ type: 'invalid_threshold',
1705
+ message: 'Threshold must be a number between 1 and 100, or null to disable',
1706
+ hint: 'Pass a number between 1 and 100, or null to disable alerts.',
1707
+ docs: 'https://webpeel.dev/docs/errors#invalid_threshold',
1708
+ },
1709
+ requestId: crypto.randomUUID(),
1710
+ });
1711
+ return;
1712
+ }
1713
+ }
1714
+ // Validate email if provided
1715
+ if (email !== null && email !== undefined) {
1716
+ if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1717
+ res.status(400).json({
1718
+ success: false,
1719
+ error: {
1720
+ type: 'invalid_email',
1721
+ message: 'Email must be a valid email address, or null to use account email',
1722
+ hint: 'Provide a valid email address (e.g. user@example.com), or null.',
1723
+ docs: 'https://webpeel.dev/docs/errors#invalid_email',
1724
+ },
1725
+ requestId: crypto.randomUUID(),
1726
+ });
1727
+ return;
1728
+ }
1729
+ }
1730
+ await pool.query('UPDATE users SET alert_threshold = $1, alert_email = $2, updated_at = now() WHERE id = $3', [threshold ?? null, email ?? null, userId]);
1731
+ res.json({ success: true });
1732
+ }
1733
+ catch (error) {
1734
+ console.error('Save alert preferences error:', error);
1735
+ res.status(500).json({
1736
+ success: false,
1737
+ error: {
1738
+ type: 'save_prefs_failed',
1739
+ message: 'Failed to save alert preferences',
1740
+ docs: 'https://webpeel.dev/docs/errors#save_prefs_failed',
1741
+ },
1742
+ requestId: crypto.randomUUID(),
1743
+ });
1744
+ }
1745
+ });
1746
+ /**
1747
+ * DELETE /v1/account
1748
+ * GDPR data deletion endpoint — deletes the authenticated user's account and ALL associated data.
1749
+ *
1750
+ * Accepts both API key auth (req.auth.keyInfo.accountId) and JWT session auth (req.user.userId).
1751
+ * This is the GDPR-friendly endpoint that skips the password/email confirmation flow,
1752
+ * intended for programmatic use (e.g. an in-app "Delete my data" button that pre-verifies
1753
+ * identity via the existing session).
1754
+ */
1755
+ router.delete('/v1/account', async (req, res) => {
1756
+ try {
1757
+ // Support both API key auth and JWT session auth
1758
+ const userId = req.user?.userId || req.auth?.keyInfo?.accountId;
1759
+ if (!userId) {
1760
+ res.status(401).json({
1761
+ success: false,
1762
+ error: {
1763
+ type: 'unauthorized',
1764
+ message: 'Authentication required. Provide a JWT token or API key.',
1765
+ hint: 'Include your API key via Authorization: Bearer <key> or X-API-Key header.',
1766
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1767
+ },
1768
+ requestId: crypto.randomUUID(),
1769
+ });
1770
+ return;
1771
+ }
1772
+ // Verify user exists before attempting deletion
1773
+ const userCheck = await pool.query('SELECT id FROM users WHERE id = $1', [userId]);
1774
+ if (userCheck.rows.length === 0) {
1775
+ res.status(404).json({
1776
+ success: false,
1777
+ error: {
1778
+ type: 'user_not_found',
1779
+ message: 'User not found',
1780
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1781
+ },
1782
+ requestId: crypto.randomUUID(),
1783
+ });
1784
+ return;
1785
+ }
1786
+ // Delegate to PostgresAuthStore which wraps everything in a transaction
1787
+ const authStore = new PostgresAuthStore();
1788
+ await authStore.deleteAccount(userId);
1789
+ res.json({
1790
+ success: true,
1791
+ message: 'Account and all associated data deleted. Your data has been permanently removed in accordance with GDPR Article 17.',
1792
+ });
1793
+ }
1794
+ catch (error) {
1795
+ console.error('GDPR account deletion error:', error);
1796
+ res.status(500).json({
1797
+ success: false,
1798
+ error: {
1799
+ type: 'deletion_failed',
1800
+ message: 'Failed to delete account',
1801
+ docs: 'https://webpeel.dev/docs/errors#deletion_failed',
1802
+ },
1803
+ requestId: crypto.randomUUID(),
1804
+ });
1805
+ }
1806
+ });
1807
+ /**
1808
+ * DELETE /v1/account
1809
+ * GDPR data deletion endpoint — deletes the authenticated user's account and ALL associated data.
1810
+ *
1811
+ * Accepts both API key auth (req.auth.keyInfo.accountId) and JWT session auth (req.user.userId).
1812
+ * This is the GDPR-compliant endpoint for programmatic "Delete my data" requests.
1813
+ */
1814
+ router.delete('/v1/account', async (req, res) => {
1815
+ try {
1816
+ // Support both API key auth and JWT session auth
1817
+ const userId = req.user?.userId || req.auth?.keyInfo?.accountId;
1818
+ if (!userId) {
1819
+ res.status(401).json({
1820
+ success: false,
1821
+ error: {
1822
+ type: 'unauthorized',
1823
+ message: 'Authentication required. Provide a JWT token or API key.',
1824
+ hint: 'Include your API key via Authorization: Bearer <key> or X-API-Key header.',
1825
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1826
+ },
1827
+ requestId: crypto.randomUUID(),
1828
+ });
1829
+ return;
1830
+ }
1831
+ // Verify user exists before attempting deletion
1832
+ const userCheck = await pool.query('SELECT id FROM users WHERE id = $1', [userId]);
1833
+ if (userCheck.rows.length === 0) {
1834
+ res.status(404).json({
1835
+ success: false,
1836
+ error: {
1837
+ type: 'user_not_found',
1838
+ message: 'User not found',
1839
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1840
+ },
1841
+ requestId: crypto.randomUUID(),
1842
+ });
1843
+ return;
1844
+ }
1845
+ // Delegate to PostgresAuthStore which wraps everything in a transaction
1846
+ const authStore = new PostgresAuthStore();
1847
+ await authStore.deleteAccount(userId);
1848
+ res.json({
1849
+ success: true,
1850
+ message: 'Account and all associated data deleted. Your data has been permanently removed in accordance with GDPR Article 17.',
1851
+ });
1852
+ }
1853
+ catch (error) {
1854
+ console.error('GDPR account deletion error:', error);
1855
+ res.status(500).json({
1856
+ success: false,
1857
+ error: {
1858
+ type: 'deletion_failed',
1859
+ message: 'Failed to delete account',
1860
+ docs: 'https://webpeel.dev/docs/errors#deletion_failed',
1861
+ },
1862
+ requestId: crypto.randomUUID(),
1863
+ });
1864
+ }
1865
+ });
1866
+ return router;
1867
+ }