diff --git a/app/web/config/environment_validator.rb b/app/web/config/environment_validator.rb index 11647188..f651ceb1 100644 --- a/app/web/config/environment_validator.rb +++ b/app/web/config/environment_validator.rb @@ -100,7 +100,7 @@ def validate_build_metadata! log_missing_build_metadata! warn_lines(*missing_build_metadata_warning_lines) - exit 1 + nil end def validate_account_configuration! @@ -154,15 +154,16 @@ def build_metadata_values def log_missing_build_metadata! SecurityLogger.log_config_validation_failure( 'build_metadata', - 'Missing BUILD_TAG or GIT_SHA' + 'Missing BUILD_TAG or GIT_SHA', + severity: :warn ) end # @return [Array] def missing_build_metadata_warning_lines [ - 'CRITICAL: Missing build metadata for production deployment!', - 'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.' + 'WARNING: Missing build metadata for production deployment.', + 'Set BUILD_TAG and GIT_SHA to improve release traceability.' ] end end diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a44a291d..de917e97 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "html2rss-frontend", "dependencies": { - "@hey-api/client-fetch": "^0.13.1", "preact": "^10.27.2", "tslib": "^2.8.1" }, @@ -1051,23 +1050,11 @@ "node": ">=18" } }, - "node_modules/@hey-api/client-fetch": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.13.1.tgz", - "integrity": "sha512-29jBRYNdxVGlx5oewFgOrkulZckpIpBIRHth3uHFn1PrL2ucMy52FvWOY3U3dVx2go1Z3kUmMi6lr07iOpUqqA==", - "deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "@hey-api/openapi-ts": "< 2" - } - }, "node_modules/@hey-api/codegen-core": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.0.tgz", "integrity": "sha512-HglL4B4QwpzocE+c8qDU6XK8zMf8W8Pcv0RpFDYxHuYALWLTnpDUuEsglC7NQ4vC1maoXsBpMbmwpco0N4QviA==", + "dev": true, "license": "MIT", "dependencies": { "@hey-api/types": "0.1.3", @@ -1089,6 +1076,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.1.tgz", "integrity": "sha512-7atnpUkT8TyUPHYPLk91j/GyaqMuwTEHanLOe50Dlx0EEvNuQqFD52Yjg8x4KU0UFL1mWlyhE+sUE/wAtQ1N2A==", + "dev": true, "license": "MIT", "dependencies": { "@jsdevtools/ono": "7.1.3", @@ -1106,6 +1094,7 @@ "version": "0.93.1", "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.93.1.tgz", "integrity": "sha512-oQJPHiVkJKesZFpoW3jfQhrSQ7xdgzai7895ENl6ZDjCaIK6bOUTly7bsu+7+0ONsGH9jbtGbkoUzC+MtY+RKg==", + "dev": true, "license": "MIT", "dependencies": { "@hey-api/codegen-core": "0.7.0", @@ -1133,6 +1122,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.1.tgz", "integrity": "sha512-uWI9047e9OVe3Ss+6vPMnRiixjRcjcBbdgpeq4IQymet3+wsn0+N/4RLDHBz1h57SemaxayPRUA0JOOsuC1qyA==", + "dev": true, "license": "MIT", "dependencies": { "@hey-api/codegen-core": "0.7.0", @@ -1157,6 +1147,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.3.tgz", "integrity": "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw==", + "dev": true, "license": "MIT", "peerDependencies": { "typescript": ">=5.5.3" @@ -1380,6 +1371,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, "license": "MIT" }, "node_modules/@mswjs/interceptors": { @@ -2102,6 +2094,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/statuses": { @@ -2240,6 +2233,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2262,6 +2256,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -2395,6 +2390,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -2410,6 +2406,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -2438,6 +2435,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -2453,6 +2451,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -2574,6 +2573,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -2707,6 +2707,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, "license": "ISC", "bin": { "color-support": "bin.js" @@ -2716,6 +2717,7 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -2725,12 +2727,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -2747,6 +2751,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2946,6 +2951,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -2962,6 +2968,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2992,6 +2999,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3022,12 +3030,14 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -3113,6 +3123,7 @@ "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3293,6 +3304,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, "license": "MIT" }, "node_modules/fdir": { @@ -3427,6 +3439,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -3739,6 +3752,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -3764,6 +3778,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -3776,6 +3791,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -3951,6 +3967,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -3973,12 +3990,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -3995,6 +4014,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4253,6 +4273,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, "license": "MIT" }, "node_modules/node-html-parser": { @@ -4290,6 +4311,7 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.0", @@ -4307,12 +4329,14 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "dev": true, "license": "MIT" }, "node_modules/nypm/node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4383,12 +4407,14 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, "license": "MIT" }, "node_modules/open": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.4.0", @@ -4429,6 +4455,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4445,6 +4472,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, "license": "MIT" }, "node_modules/pathval": { @@ -4461,6 +4489,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -4487,6 +4516,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -4584,6 +4614,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -4632,6 +4663,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -4756,6 +4788,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4806,6 +4839,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4852,6 +4886,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4864,6 +4899,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5213,6 +5249,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5492,6 +5529,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5607,6 +5645,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", diff --git a/frontend/package.json b/frontend/package.json index d0c1378c..d3051a95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,6 @@ "test:e2e": "playwright test" }, "dependencies": { - "@hey-api/client-fetch": "^0.13.1", "preact": "^10.27.2", "tslib": "^2.8.1" }, diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 07d86c27..a0082a77 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -412,6 +412,128 @@ describe('useFeedConversion', () => { }); }); + it('does not auto-retry browserless for unauthorized faraday failures', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + error: { message: 'Unauthorized' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') + ).rejects.toThrow('Unauthorized'); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('Unauthorized'); + }); + + it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + error: { code: 'BAD_REQUEST', message: 'Input rejected' }, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') + ).rejects.toThrow('Input rejected'); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('Input rejected'); + }); + + it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com/articles', + strategy: 'browserless', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch https://example.com/articles', + }, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: createdFeed, + }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + }) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + }); + + const retryRequest = fetchMock.mock.calls[1]?.[0] as Request; + expect(await retryRequest.clone().json()).toEqual({ + url: 'https://example.com/articles', + strategy: 'browserless', + }); + expect(result.current.result?.retry).toEqual({ + automatic: true, + from: 'faraday', + to: 'browserless', + }); + }); + it('does not offer a duplicate manual retry after automatic fallback also fails', async () => { fetchMock .mockResolvedValueOnce( @@ -459,4 +581,125 @@ describe('useFeedConversion', () => { 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' ); }); + + it('ignores stale preview updates from an earlier conversion request', async () => { + const feedA = { + id: 'feed-a-id', + name: 'Feed A', + url: 'https://example.com/a', + strategy: 'faraday', + feed_token: 'feed-a-token', + public_url: 'https://example.com/feed-a', + json_public_url: 'https://example.com/feed-a.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + const feedB = { + id: 'feed-b-id', + name: 'Feed B', + url: 'https://example.com/b', + strategy: 'faraday', + feed_token: 'feed-b-token', + public_url: 'https://example.com/feed-b', + json_public_url: 'https://example.com/feed-b.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + let resolvePreviewA: ((value: Response) => void) | null = null; + const previewAPromise = new Promise((resolve) => { + resolvePreviewA = resolve; + }); + let resolvePreviewB: ((value: Response) => void) | null = null; + const previewBPromise = new Promise((resolve) => { + resolvePreviewB = resolve; + }); + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { feed: feedA }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockReturnValueOnce(previewAPromise as Promise) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { feed: feedB }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockReturnValueOnce(previewBPromise as Promise); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com/a', 'faraday', 'testtoken'); + }); + await act(async () => { + await result.current.convertFeed('https://example.com/b', 'faraday', 'testtoken'); + }); + + expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); + + resolvePreviewB?.( + new Response( + JSON.stringify({ + items: [ + { + title: 'Preview B', + content_text: 'Current preview item', + url: 'https://example.com/b/item', + date_published: '2024-01-02T00:00:00Z', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + } + ) + ); + + await waitFor(() => { + expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); + expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); + }); + + resolvePreviewA?.( + new Response( + JSON.stringify({ + items: [ + { + title: 'Preview A', + content_text: 'Stale preview item', + url: 'https://example.com/a/item', + date_published: '2024-01-03T00:00:00Z', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + } + ) + ); + + await waitFor(() => { + expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); + expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); + }); + }); }); diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 91da5024..88d58560 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -116,11 +116,11 @@ export type RenderFeedByTokenData = { export type RenderFeedByTokenErrors = { /** - * returns JSON Feed-shaped errors when requested by json extension + * returns unauthorized for invalid tokens */ 401: string; /** - * returns JSON Feed-shaped forbidden errors when requested through Accept + * returns forbidden when auto source is disabled */ 403: string; /** diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index 3ff52d31..92753d39 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -25,9 +25,11 @@ interface ConversionState { interface ConversionError extends Error { manualRetryStrategy?: string; - autoRetryAttempted?: boolean; } +const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; +const NON_RETRYABLE_ERROR_CODES = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN']); + export function useFeedConversion() { const requestIdRef = useRef(0); const [state, setState] = useState({ @@ -50,32 +52,22 @@ export function useFeedConversion() { const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; - setState((prev) => ({ ...prev, isConverting: true, error: null })); + markConversionStarted(setState); try { const feed = await requestFeedCreation(normalizedUrl, requestedStrategy, token); - const result = { - feed, - preview: buildLoadingPreviewState(), - retry: null, - }; - - setState((prev) => ({ ...prev, isConverting: false, result, error: null })); - void hydratePreview(feed, requestId, null, setState, requestIdRef); - return result; + return publishCreatedFeed(feed, null, requestId, setState, requestIdRef); } catch (firstError) { if (shouldAutoRetry(requestedStrategy, fallbackStrategy, firstError)) { try { const feed = await requestFeedCreation(normalizedUrl, fallbackStrategy, token); - const result = { + return publishCreatedFeed( feed, - preview: buildLoadingPreviewState(), - retry: { automatic: true, from: requestedStrategy, to: fallbackStrategy }, - }; - - setState((prev) => ({ ...prev, isConverting: false, result, error: null })); - void hydratePreview(feed, requestId, result.retry, setState, requestIdRef); - return result; + { automatic: true, from: requestedStrategy, to: fallbackStrategy }, + requestId, + setState, + requestIdRef + ); } catch (secondError) { const message = buildRetryFailureMessage( firstError, @@ -83,33 +75,12 @@ export function useFeedConversion() { requestedStrategy, fallbackStrategy ); - const retryError = buildConversionError(message, { - manualRetryStrategy: undefined, - autoRetryAttempted: true, - }); - - setState((prev) => ({ - ...prev, - isConverting: false, - error: message, - result: null, - })); - throw retryError; + failConversion(setState, message, { manualRetryStrategy: undefined }); } } const message = toErrorMessage(firstError); - const retryError = buildConversionError(message, { - manualRetryStrategy: alternateStrategy(requestedStrategy), - }); - - setState((prev) => ({ - ...prev, - isConverting: false, - error: message, - result: null, - })); - throw retryError; + failConversion(setState, message, { manualRetryStrategy: alternateStrategy(requestedStrategy) }); } }; @@ -143,7 +114,7 @@ async function loadPreview(feed: FeedRecord): Promise 0 ? null : 'Preview unavailable right now.', + error: items.length > 0 ? null : PREVIEW_UNAVAILABLE_MESSAGE, isLoading: false, }; } @@ -243,19 +214,7 @@ function shouldAutoRetry( error: unknown ): fallbackStrategy is string { if (strategy !== 'faraday' || !fallbackStrategy) return false; - - const normalized = toErrorMessage(error).toLowerCase(); - return !( - normalized.includes('unauthorized') || - normalized.includes('bad request') || - normalized.includes('forbidden') || - normalized.includes('access token') || - normalized.includes('authentication') || - normalized.includes('invalid response format') || - normalized.includes('network error') || - normalized.includes('url') || - normalized.includes('unsupported strategy') - ); + return retryableForFallback(error); } function buildRetryFailureMessage( @@ -279,30 +238,124 @@ function buildConversionError(message: string, metadata: Partial { + const details = extractErrorDetails(error); + const detailsMessage = details?.message?.toLowerCase(); + if ( + detailsMessage && + (detailsMessage.includes('not valid json') || detailsMessage.includes('unexpected token')) + ) { + return 'Invalid response format from feed creation API'; + } + if (details?.message) return details.message; if (error instanceof SyntaxError) return 'Invalid response format from feed creation API'; - if (error instanceof Error) return error.message; - if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error) { + const normalizedMessage = error.message.toLowerCase(); + if (normalizedMessage.includes('not valid json') || normalizedMessage.includes('unexpected token')) { + return 'Invalid response format from feed creation API'; + } - const message = extractMessage(error); - return message ?? 'An unexpected error occurred'; + return error.message; + } + if (typeof error === 'string' && error.trim()) return error; + return 'An unexpected error occurred'; }; const toPreviewErrorMessage = (error: unknown): string => { - if (error instanceof SyntaxError) return 'Preview unavailable right now.'; + if (error instanceof SyntaxError) return PREVIEW_UNAVAILABLE_MESSAGE; if (error instanceof Error && error.message.trim()) return error.message; - return 'Preview unavailable right now.'; + return PREVIEW_UNAVAILABLE_MESSAGE; }; -const extractMessage = (error: unknown): string | null => { +function markConversionStarted( + setState: (value: ConversionState | ((prev: ConversionState) => ConversionState)) => void +) { + setState((prev) => ({ ...prev, isConverting: true, error: null })); +} + +function publishCreatedFeed( + feed: FeedRecord, + retry: CreatedFeedResult['retry'], + requestId: number, + setState: (value: ConversionState | ((prev: ConversionState) => ConversionState)) => void, + requestIdRef: { current: number } +): CreatedFeedResult { + const result: CreatedFeedResult = { + feed, + preview: buildLoadingPreviewState(), + retry, + }; + + setState((prev) => ({ ...prev, isConverting: false, result, error: null })); + void hydratePreview(feed, requestId, retry, setState, requestIdRef); + return result; +} + +function failConversion( + setState: (value: ConversionState | ((prev: ConversionState) => ConversionState)) => void, + message: string, + metadata: Partial +): never { + setState((prev) => ({ + ...prev, + isConverting: false, + error: message, + result: null, + })); + + throw buildConversionError(message, metadata); +} + +const extractErrorDetails = (error: unknown): { message?: string; code?: string; status?: number } | null => { if (!error || typeof error !== 'object') return null; - const candidate = - (error as { error?: { message?: unknown }; message?: unknown }).error?.message ?? - (error as { message?: unknown }).message; + const candidate = error as { + error?: { message?: unknown; code?: unknown; status?: unknown }; + message?: unknown; + code?: unknown; + status?: unknown; + }; - return typeof candidate === 'string' && candidate.trim() ? candidate : null; + const message = normalizeString(candidate.error?.message ?? candidate.message); + const code = normalizeString(candidate.error?.code ?? candidate.code); + const status = normalizeStatus(candidate.error?.status ?? candidate.status); + return { message, code, status }; }; +function retryableForFallback(error: unknown): boolean { + const details = extractErrorDetails(error); + const errorCode = details?.code?.toUpperCase(); + const status = details?.status; + if (errorCode && NON_RETRYABLE_ERROR_CODES.has(errorCode)) return false; + if (status && status < 500) return false; + + const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); + if (!details?.code && (message.includes('unauthorized') || message.includes('forbidden'))) return false; + if (!details?.code && message.includes('bad request')) return false; + if (message.includes('access token') || message.includes('authentication')) return false; + if (message.includes('unsupported strategy')) return false; + if (message.includes('invalid response format')) return false; + if (message.includes('not valid json') || message.includes('unexpected token')) return false; + if (message === 'network error') return false; + if (error instanceof SyntaxError) return false; + + if (status && status >= 500) return true; + if (message.includes('failed to fetch http')) return true; + return message.includes('internal server error') || message.includes('upstream timeout'); +} + +function networkFailure(error: unknown, normalizedMessage: string): boolean { + if (error instanceof TypeError) return true; + return normalizedMessage.includes('network error'); +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function normalizeStatus(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + function normalizePreviewText(value?: string): string | null { if (!value) return null; diff --git a/public/openapi.yaml b/public/openapi.yaml index b94f4552..ab990919 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -250,7 +250,7 @@ paths: ErrorInternal Server Error schema: type: string - description: returns JSON Feed-shaped errors when requested by json extension + description: returns unauthorized for invalid tokens '403': content: application/feed+json: @@ -263,8 +263,7 @@ paths: ErrorInternal Server Error schema: type: string - description: returns JSON Feed-shaped forbidden errors when requested through - Accept + description: returns forbidden when auto source is disabled '500': content: application/feed+json: diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index aebed989..a26256a1 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -475,17 +475,8 @@ def expected_featured_feeds end it 'normalizes hostname-only input to https before feed creation', :aggregate_failures do - allow(Html2rss::Web::AutoSource).to receive(:create_stable_feed).and_call_original - post_feed_request(url: 'example.com/articles', strategy: 'faraday') - expect(Html2rss::Web::AutoSource).to have_received(:create_stable_feed).with( - anything, - 'https://example.com/articles', - kind_of(Hash), - 'faraday' - ) - expect(last_response.status).to eq(201) json = expect_success_response(last_response) expect(json.dig('data', 'feed', 'url')).to eq('https://example.com/articles') diff --git a/spec/html2rss/web/environment_validator_spec.rb b/spec/html2rss/web/environment_validator_spec.rb index 75e3b4c7..1d4cae25 100644 --- a/spec/html2rss/web/environment_validator_spec.rb +++ b/spec/html2rss/web/environment_validator_spec.rb @@ -64,7 +64,7 @@ def stub_validation_logging .with('secret_key', 'Invalid or weak secret key') end - it 'logs missing build metadata before exiting' do + it 'logs missing build metadata as a warning' do stub_validation_logging allow(Html2rss::Web::AccountManager).to receive(:accounts).and_return([]) @@ -74,11 +74,11 @@ def stub_validation_logging 'BUILD_TAG' => nil, 'GIT_SHA' => nil ) do - expect { described_class.validate_production_security! }.to raise_error(SystemExit) + expect { described_class.validate_production_security! }.not_to raise_error end expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure) - .with('build_metadata', 'Missing BUILD_TAG or GIT_SHA') + .with('build_metadata', 'Missing BUILD_TAG or GIT_SHA', severity: :warn) end end diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 73f1cf18..5705a7d5 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -72,6 +72,19 @@ end merge_responses = lambda do |existing_responses, new_responses| + canonical_description = lambda do |*responses| + descriptions = responses + .filter_map { |response| response['description']&.to_s&.strip } + .reject(&:empty?) + .uniq + + next nil if descriptions.empty? + + # Prefer the most generic/canonical wording when duplicate examples define + # the same status differently. + descriptions.min_by { |description| [description.length, description] } + end + statuses = existing_responses.keys | new_responses.keys statuses.each_with_object({}) do |status, merged_responses| @@ -96,7 +109,7 @@ merged_response['headers'] = current_headers.merge(incoming_headers) end - merged_response['description'] ||= current['description'] || incoming['description'] + merged_response['description'] = canonical_description.call(current, incoming) merged_responses[status] = merged_response end end