From 55875b97aaee619d79bf93c8c69c3546f024f8f5 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:40:29 +0300 Subject: [PATCH 01/36] chore: add eslint flat config for typescript and react 19 Flat config on ESLint 9: typescript-eslint, eslint-plugin-react on the automatic JSX runtime, and react-hooks. eslint-config-prettier goes last so formatting stays in .prettierrc and the two never disagree. eslint-plugin-react-hooks v7 ships its config in ESLint 10's plugin-array form, so the plugin is registered by hand and only its rules are spread. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 553 ++++++++++++++++++++++++++++++++++++++++++++++- eslint.config.js | 50 +++++ package.json | 24 +- 3 files changed, 617 insertions(+), 10 deletions(-) create mode 100644 eslint.config.js diff --git a/bun.lock b/bun.lock index a54f2a5..7d4ac1b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,35 +5,580 @@ "": { "name": "bun-react-template", "dependencies": { - "react": "^19", - "react-dom": "^19", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "zod": "^4.6.5", }, "devDependencies": { + "@eslint/js": "^9", "@types/bun": "latest", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "eslint": "^9", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", + "typescript": "^5", + "typescript-eslint": "^8.70.0", }, }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="], + + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/type-utils": "8.70.0", "@typescript-eslint/utils": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.0", "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0" } }, "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.70.0", "", {}, "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.0", "@typescript-eslint/tsconfig-utils": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "es-shim-unscopables": "^1.1.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="], + + "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], + + "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.429", "", {}, "sha512-/1ENIE3cx4HTIx4IfPZFaOunJmsrSVTnj6coXoRVbiJUbkeTyFkJvBeWGkdgh08OhFbxYLMT1kbkwFVSarq6Ow=="], + + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.4.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.12.0", "", {}, "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="], + + "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.1.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "get-intrinsic": "^1.3.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.4", "set-function-name": "^2.0.2", "side-channel": "^1.1.1" } }, "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.70.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.0", "@typescript-eslint/parser": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0f83060 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,50 @@ +import js from "@eslint/js"; +import prettier from "eslint-config-prettier"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist/**", "node_modules/**", "**/*.d.ts"] }, + + js.configs.recommended, + tseslint.configs.recommended, + + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + rules: { + // A leading underscore is the opt-out: it is how a callback documents a parameter of the + // signature it is required to have but does not use. + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + }, + }, + + { + files: ["**/*.{jsx,tsx}"], + ...react.configs.flat.recommended, + languageOptions: { + ...react.configs.flat.recommended.languageOptions, + globals: globals.browser, + }, + // eslint-plugin-react-hooks v7 ships its config in ESLint 10's `plugins: [name]` form, which + // ESLint 9 rejects, so the plugin is registered by hand and only its rules are spread. + plugins: { ...react.configs.flat.recommended.plugins, "react-hooks": reactHooks }, + settings: { react: { version: "detect" } }, + rules: { + ...react.configs.flat.recommended.rules, + // React 19's automatic JSX runtime: no React import, so no in-scope check. + ...react.configs.flat["jsx-runtime"].rules, + ...reactHooks.configs["recommended-latest"].rules, + }, + }, + + // Last, so it wins: formatting lives in .prettierrc, not in lint rules. + prettier, +); diff --git a/package.json b/package.json index 4e3b6d0..4a2229c 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,27 @@ "scripts": { "dev": "bun --hot src/index.ts", "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", - "start": "NODE_ENV=production bun src/index.ts" + "start": "NODE_ENV=production bun src/index.ts", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { - "react": "^19", - "react-dom": "^19" + "react": "^19.3.0", + "react-dom": "^19.3.0", + "zod": "^4.6.5" }, "devDependencies": { - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/bun": "latest" + "@eslint/js": "^9", + "@types/bun": "latest", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "eslint": "^9", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", + "typescript": "^5", + "typescript-eslint": "^8.70.0" } } From 00f56a71807e02113b3d98a927798a6b9f3c94a6 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:40:30 +0300 Subject: [PATCH 02/36] test: specify Result, red Covers every exported function, including the branches that are easy to get wrong: map over an err is a no-op, andThen short-circuits without calling the next step, and fromPromise hands the thrown value to the caller's mapper untouched. Red against a stub: 16 failing, 0 passing. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/result.test.ts | 134 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/lib/result.test.ts diff --git a/src/lib/result.test.ts b/src/lib/result.test.ts new file mode 100644 index 0000000..6be6b25 --- /dev/null +++ b/src/lib/result.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; + +import type { Result } from "./result"; +import { andThen, err, fromPromise, isErr, isOk, map, ok, unwrapOr } from "./result"; + +type NotFound = { kind: "not_found"; id: string }; +type Denied = { kind: "denied" }; + +const notFound: NotFound = { kind: "not_found", id: "list-1" }; + +describe("ok", () => { + test("carries the value on an ok branch", () => { + const result = ok(42); + + expect(result.ok).toBe(true); + expect(result.value).toBe(42); + }); +}); + +describe("err", () => { + test("carries the error on an err branch", () => { + const result = err(notFound); + + expect(result.ok).toBe(false); + expect(result.error).toEqual(notFound); + }); +}); + +describe("isOk", () => { + test("is true for ok and narrows to the value", () => { + const result: Result = ok(1); + + expect(isOk(result)).toBe(true); + if (isOk(result)) expect(result.value).toBe(1); + }); + + test("is false for err", () => { + expect(isOk(err(notFound))).toBe(false); + }); +}); + +describe("isErr", () => { + test("is true for err and narrows to the error", () => { + const result: Result = err(notFound); + + expect(isErr(result)).toBe(true); + if (isErr(result)) expect(result.error.kind).toBe("not_found"); + }); + + test("is false for ok", () => { + expect(isErr(ok(1))).toBe(false); + }); +}); + +describe("map", () => { + test("applies the function to an ok value", () => { + expect(map(ok(2), (n) => n * 3)).toEqual(ok(6)); + }); + + test("is a no-op over an err", () => { + let called = false; + const result = map(err(notFound) as Result, (n) => { + called = true; + return n * 3; + }); + + expect(called).toBe(false); + expect(result).toEqual(err(notFound)); + }); +}); + +describe("andThen", () => { + test("chains the next step on an ok value", () => { + const result = andThen(ok("list-1"), (id) => ok(id.length)); + + expect(result).toEqual(ok(6)); + }); + + test("returns the err the next step produced", () => { + const denied: Denied = { kind: "denied" }; + const result = andThen(ok("list-1"), (): Result => err(denied)); + + expect(result).toEqual(err(denied)); + }); + + test("short-circuits on an err without calling the next step", () => { + let called = false; + const result = andThen(err(notFound) as Result, (id) => { + called = true; + return ok(id.length); + }); + + expect(called).toBe(false); + expect(result).toEqual(err(notFound)); + }); +}); + +describe("unwrapOr", () => { + test("returns the value on an ok branch", () => { + expect(unwrapOr(ok(7), 0)).toBe(7); + }); + + test("returns the fallback on an err branch", () => { + expect(unwrapOr(err(notFound) as Result, 0)).toBe(0); + }); +}); + +describe("fromPromise", () => { + test("wraps a resolved promise as ok", async () => { + const result = await fromPromise(Promise.resolve("session"), () => notFound); + + expect(result).toEqual(ok("session")); + }); + + test("maps a thrown value into the caller's typed error", async () => { + const boom = new Error("connection reset"); + const result = await fromPromise(Promise.reject(boom), (cause) => ({ + kind: "not_found", + id: String(cause), + })); + + expect(result).toEqual(err({ kind: "not_found", id: "Error: connection reset" })); + }); + + test("passes the thrown value through untouched, including non-Error throws", async () => { + let seen: unknown = undefined; + await fromPromise(Promise.reject("a string throw"), (cause) => { + seen = cause; + return notFound; + }); + + expect(seen).toBe("a string throw"); + }); +}); From 6bfe07c27a5a6f624596e21b6a442ef79a4ebd84 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:40:30 +0300 Subject: [PATCH 03/36] feat: add Result for errors as values ok/err/isOk/isErr/map/andThen/unwrapOr/fromPromise. E is unconstrained so a boundary can carry a raw error before it has settled on its variants; andThen widens to E | F so a chained step can add a failure the first could not produce. fromPromise is the wrapper for code that throws, and takes the mapper from the caller because only the caller knows what a failure means. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/result.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/lib/result.ts diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 0000000..21dc0e0 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,55 @@ +/** + * Expected failures travel as values, not exceptions (CONVENTIONS.md, "Errors are values"). + * + * `E` is deliberately unconstrained: errors here are discriminated unions with a `kind`, and + * pinning `E extends { kind: string }` would block the one place a raw error is still useful — + * a boundary that has not decided on its variants yet. + */ +export type Ok = { readonly ok: true; readonly value: T }; +export type Err = { readonly ok: false; readonly error: E }; +export type Result = Ok | Err; + +export function ok(value: T): Ok { + return { ok: true, value }; +} + +export function err(error: E): Err { + return { ok: false, error }; +} + +export function isOk(result: Result): result is Ok { + return result.ok; +} + +export function isErr(result: Result): result is Err { + return !result.ok; +} + +export function map(result: Result, fn: (value: T) => U): Result { + return isOk(result) ? ok(fn(result.value)) : result; +} + +/** + * `E | F` lets a chained step add a failure the first step could not produce, so the union of + * error variants grows with the chain instead of being flattened to the first one. + */ +export function andThen(result: Result, fn: (value: T) => Result): Result { + return isOk(result) ? fn(result.value) : result; +} + +export function unwrapOr(result: Result, fallback: T): T { + return isOk(result) ? result.value : fallback; +} + +/** + * The boundary wrapper for code that throws — Better Auth, the Postgres driver. `onThrow` takes + * the thrown value as `unknown` because JavaScript can throw anything, and it is the caller, not + * this function, that knows which domain error the failure means. + */ +export async function fromPromise(promise: Promise, onThrow: (cause: unknown) => E): Promise> { + try { + return ok(await promise); + } catch (cause) { + return err(onThrow(cause)); + } +} From a920d5f7c3784b3e3babcc34147185bf9e7d5402 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:42:15 +0300 Subject: [PATCH 04/36] chore: add signatures for the Phase 1 pure core `can()` and the REST schemas, as types and signatures only, so the tests that specify them typecheck. The implementing agent fills them in. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/permissions.ts | 50 +++++++++++++++++++++++++++++++++++ src/lib/schemas.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/schemas.ts diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..095951d --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,50 @@ +/** + * The pure core of access control (ADR-0005). Roles are a constant map from a + * Role name to a set of Permissions, defined here in code and identical on + * every installation; the Membership row stores only its `role`. `can()` is the + * only reader of a Role in the app, so moving Roles into the database later + * changes this file and nothing else. + */ + +export const ROLES = ["owner", "editor"] as const; + +export type Role = (typeof ROLES)[number]; + +export const PERMISSIONS = [ + "list:read", + "list:update", + "list:delete", + "membership:remove", + "membership:promote", + "item:create", + "item:update", + "item:delete", + "item:check", + "item:clear_checked", + "item:uncheck_all", +] as const; + +export type Permission = (typeof PERMISSIONS)[number]; + +/** The minimum `can()` needs of an Account. Richer objects satisfy it. */ +export type Account = { readonly id: string }; + +/** The link between an Account and a List, carrying exactly one Role. */ +export type Membership = { + readonly accountId: string; + readonly listId: string; + readonly role: Role; +}; + +/** + * The minimum `can()` needs of a List. The Memberships are the only data that + * answers the question, so the pure core is handed them rather than querying. + */ +export type List = { + readonly id: string; + readonly memberships: readonly Membership[]; +}; + +export function can(_account: Account, _permission: Permission, _list: List): boolean { + throw new Error("not implemented"); +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..38201ff --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +/** + * The pure core of request validation: the Zod schemas for the REST surface. + * Every schema is strict — an unknown key is a client bug, and rejecting it is + * what guarantees no schema ever admits a `position`, since Items have no + * inherent order and the server never orders them. + */ + +export const MAX_NAME_LENGTH = 200; +export const MAX_UNIT_LENGTH = 32; +export const MAX_NOTE_LENGTH = 2000; + +export type CreateListInput = { name: string }; + +export type UpdateListInput = { name: string }; + +export type CreateItemInput = { + name: string; + quantity?: number; + unit?: string; + note?: string; +}; + +/** + * A partial update. `null` clears an optional field; omitting it leaves the + * field alone. A `unit` may not be set without a `quantity` in the same + * payload, because a unit with nothing to measure is not a quantity. + */ +export type UpdateItemInput = { + name?: string; + quantity?: number | null; + unit?: string | null; + note?: string | null; +}; + +export type SetItemCheckedInput = { checked: boolean }; + +export type ClearCheckedItemsInput = Record; + +export type UncheckAllItemsInput = Record; + +const notImplemented = (): z.ZodType => + z.any().transform((): T => { + throw new Error("not implemented"); + }) as unknown as z.ZodType; + +export const createListSchema = notImplemented(); + +export const updateListSchema = notImplemented(); + +export const createItemSchema = notImplemented(); + +export const updateItemSchema = notImplemented(); + +export const setItemCheckedSchema = notImplemented(); + +export const clearCheckedItemsSchema = notImplemented(); + +export const uncheckAllItemsSchema = notImplemented(); From 76e2ca56edbe322ed2b15fd218775db01d5346aa Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:42:15 +0300 Subject: [PATCH 05/36] test: specify can() and the REST schemas, red Written from issue #2 alone, before any implementation exists, so the tests define the contract rather than describe it. All 102 fail with "not implemented". Covers ADR-0005 (Owner holds every Permission, Editor every Item Permission), ADR-0004 (an Ownerless List keeps working, minus the Owner-only Permissions), and the rule that an Account with no Membership cannot see a List at all. Every schema is strict, so no schema admits a `position`. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/permissions.test.ts | 171 ++++++++++++++++ src/lib/schemas.test.ts | 377 ++++++++++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+) create mode 100644 src/lib/permissions.test.ts create mode 100644 src/lib/schemas.test.ts diff --git a/src/lib/permissions.test.ts b/src/lib/permissions.test.ts new file mode 100644 index 0000000..f2d4185 --- /dev/null +++ b/src/lib/permissions.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; + +import type { Account, List, Membership, Permission } from "./permissions"; +import { PERMISSIONS, can } from "./permissions"; + +const owner: Account = { id: "account-owner" }; +const secondOwner: Account = { id: "account-second-owner" }; +const editor: Account = { id: "account-editor" }; +const stranger: Account = { id: "account-stranger" }; + +const LIST_ID = "list-1"; +const OTHER_LIST_ID = "list-2"; + +const ITEM_PERMISSIONS = PERMISSIONS.filter((permission) => permission.startsWith("item:")); + +/** CONTEXT.md: the Permissions no Role but Owner holds. */ +const OWNER_ONLY_PERMISSIONS: Permission[] = ["list:delete", "membership:remove", "membership:promote"]; + +function membership(account: Account, role: Membership["role"], listId = LIST_ID): Membership { + return { accountId: account.id, listId, role }; +} + +function listWith(...memberships: Membership[]): List { + return { id: LIST_ID, memberships }; +} + +describe("can, Owner", () => { + test("holds every Permission", () => { + const list = listWith(membership(owner, "owner")); + + for (const permission of PERMISSIONS) { + expect(can(owner, permission, list)).toBe(true); + } + }); + + test("holds the Permissions no other Role holds", () => { + const list = listWith(membership(owner, "owner")); + + for (const permission of OWNER_ONLY_PERMISSIONS) { + expect(can(owner, permission, list)).toBe(true); + } + }); + + test("a List may have several Owners, and each holds every Permission", () => { + const list = listWith(membership(owner, "owner"), membership(secondOwner, "owner")); + + for (const permission of PERMISSIONS) { + expect(can(secondOwner, permission, list)).toBe(true); + } + }); +}); + +describe("can, Editor", () => { + test("holds every Item Permission", () => { + const list = listWith(membership(editor, "editor")); + + expect(ITEM_PERMISSIONS.length).toBeGreaterThan(0); + for (const permission of ITEM_PERMISSIONS) { + expect(can(editor, permission, list)).toBe(true); + } + }); + + test("may read and rename the List", () => { + const list = listWith(membership(editor, "editor")); + + expect(can(editor, "list:read", list)).toBe(true); + expect(can(editor, "list:update", list)).toBe(true); + }); + + test("may not delete the List, remove a Member, or promote a Member to Owner", () => { + const list = listWith(membership(editor, "editor")); + + for (const permission of OWNER_ONLY_PERMISSIONS) { + expect(can(editor, permission, list)).toBe(false); + } + }); + + test("differs from Owner in exactly the Owner-only Permissions", () => { + const ownerList = listWith(membership(owner, "owner")); + const editorList = listWith(membership(editor, "editor")); + + const ownerOnly = PERMISSIONS.filter( + (permission) => can(owner, permission, ownerList) && !can(editor, permission, editorList), + ); + + expect([...ownerOnly].sort()).toEqual([...OWNER_ONLY_PERMISSIONS].sort()); + }); +}); + +describe("can, no Membership", () => { + test("an Account with no Membership on a List cannot see it at all", () => { + const list = listWith(membership(owner, "owner"), membership(editor, "editor")); + + for (const permission of PERMISSIONS) { + expect(can(stranger, permission, list)).toBe(false); + } + }); + + test("a List with no Memberships grants nothing to anyone", () => { + const list = listWith(); + + for (const permission of PERMISSIONS) { + expect(can(owner, permission, list)).toBe(false); + } + }); + + test("a Membership on another List grants nothing on this one", () => { + const list = listWith(membership(owner, "owner", OTHER_LIST_ID)); + + for (const permission of PERMISSIONS) { + expect(can(owner, permission, list)).toBe(false); + } + }); + + test("another Account's Owner Membership grants nothing to a stranger", () => { + const list = listWith(membership(owner, "owner")); + + expect(can(stranger, "list:read", list)).toBe(false); + expect(can(stranger, "item:create", list)).toBe(false); + }); +}); + +describe("can, Ownerless List (ADR-0004)", () => { + test("Editors keep every Item Permission", () => { + const list = listWith(membership(editor, "editor")); + + for (const permission of ITEM_PERMISSIONS) { + expect(can(editor, permission, list)).toBe(true); + } + }); + + test("Editors keep reading and renaming the List", () => { + const list = listWith(membership(editor, "editor")); + + expect(can(editor, "list:read", list)).toBe(true); + expect(can(editor, "list:update", list)).toBe(true); + }); + + test("the Owner-only Permissions are unavailable to every Member", () => { + const list = listWith( + membership(editor, "editor"), + membership(secondOwner, "editor"), + membership(stranger, "editor"), + ); + + for (const account of [editor, secondOwner, stranger]) { + for (const permission of OWNER_ONLY_PERMISSIONS) { + expect(can(account, permission, list)).toBe(false); + } + } + }); +}); + +describe("can, several Memberships on one List", () => { + test("each Account is judged by its own Membership", () => { + const list = listWith(membership(owner, "owner"), membership(editor, "editor"), membership(secondOwner, "owner")); + + expect(can(owner, "list:delete", list)).toBe(true); + expect(can(secondOwner, "list:delete", list)).toBe(true); + expect(can(editor, "list:delete", list)).toBe(false); + expect(can(editor, "item:check", list)).toBe(true); + expect(can(stranger, "item:check", list)).toBe(false); + }); + + test("an Account's own Role decides, not the strongest Role present", () => { + const list = listWith(membership(owner, "owner"), membership(editor, "editor")); + + expect(can(editor, "membership:promote", list)).toBe(false); + expect(can(editor, "membership:remove", list)).toBe(false); + }); +}); diff --git a/src/lib/schemas.test.ts b/src/lib/schemas.test.ts new file mode 100644 index 0000000..3e5039c --- /dev/null +++ b/src/lib/schemas.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, test } from "bun:test"; +import type { z } from "zod"; + +import { + MAX_NAME_LENGTH, + MAX_NOTE_LENGTH, + MAX_UNIT_LENGTH, + clearCheckedItemsSchema, + createItemSchema, + createListSchema, + setItemCheckedSchema, + uncheckAllItemsSchema, + updateItemSchema, + updateListSchema, +} from "./schemas"; + +function accept(schema: z.ZodType, input: unknown): T { + const result = schema.safeParse(input); + + expect(result.success).toBe(true); + if (!result.success) throw new Error("unreachable"); + return result.data; +} + +function reject(schema: z.ZodType, input: unknown): void { + expect(schema.safeParse(input).success).toBe(false); +} + +const SURFACE: { name: string; schema: z.ZodType; valid: unknown }[] = [ + { name: "createListSchema", schema: createListSchema, valid: { name: "Groceries" } }, + { name: "updateListSchema", schema: updateListSchema, valid: { name: "Groceries" } }, + { name: "createItemSchema", schema: createItemSchema, valid: { name: "Milk" } }, + { name: "updateItemSchema", schema: updateItemSchema, valid: { name: "Milk" } }, + { name: "setItemCheckedSchema", schema: setItemCheckedSchema, valid: { checked: true } }, + { name: "clearCheckedItemsSchema", schema: clearCheckedItemsSchema, valid: {} }, + { name: "uncheckAllItemsSchema", schema: uncheckAllItemsSchema, valid: {} }, +]; + +describe("the REST surface, every schema", () => { + for (const { name, schema, valid } of SURFACE) { + test(`${name} accepts its minimal valid payload`, () => { + accept(schema, valid); + }); + + test(`${name} rejects a position — Items have no inherent order`, () => { + reject(schema, { ...(valid as object), position: 1 }); + }); + + test(`${name} rejects an unknown key`, () => { + reject(schema, { ...(valid as object), sortOrder: "asc" }); + }); + + test(`${name} rejects a payload that is not an object`, () => { + reject(schema, null); + reject(schema, undefined); + reject(schema, "name"); + reject(schema, 1); + reject(schema, []); + }); + } +}); + +describe("createListSchema", () => { + test("accepts a named List", () => { + expect(accept(createListSchema, { name: "Groceries" })).toEqual({ name: "Groceries" }); + }); + + test("trims the name", () => { + expect(accept(createListSchema, { name: " Groceries " })).toEqual({ name: "Groceries" }); + }); + + test("accepts a name at the maximum length", () => { + const name = "a".repeat(MAX_NAME_LENGTH); + + expect(accept(createListSchema, { name })).toEqual({ name }); + }); + + test("rejects a missing name", () => { + reject(createListSchema, {}); + }); + + test("rejects a name that is not a string", () => { + reject(createListSchema, { name: 1 }); + reject(createListSchema, { name: null }); + reject(createListSchema, { name: ["Groceries"] }); + }); + + test("rejects an empty name", () => { + reject(createListSchema, { name: "" }); + }); + + test("rejects a whitespace-only name", () => { + reject(createListSchema, { name: " " }); + reject(createListSchema, { name: "\t\n" }); + }); + + test("rejects a name over the maximum length", () => { + reject(createListSchema, { name: "a".repeat(MAX_NAME_LENGTH + 1) }); + }); +}); + +describe("updateListSchema", () => { + test("accepts a new name", () => { + expect(accept(updateListSchema, { name: "Weekly shop" })).toEqual({ name: "Weekly shop" }); + }); + + test("trims the name", () => { + expect(accept(updateListSchema, { name: " Weekly shop " })).toEqual({ name: "Weekly shop" }); + }); + + test("rejects a missing name", () => { + reject(updateListSchema, {}); + }); + + test("rejects an empty or whitespace-only name", () => { + reject(updateListSchema, { name: "" }); + reject(updateListSchema, { name: " " }); + }); +}); + +describe("createItemSchema", () => { + test("accepts a name alone", () => { + expect(accept(createItemSchema, { name: "Milk" })).toEqual({ name: "Milk" }); + }); + + test("accepts a numeric quantity with a unit", () => { + expect(accept(createItemSchema, { name: "Milk", quantity: 2, unit: "l" })).toEqual({ + name: "Milk", + quantity: 2, + unit: "l", + }); + }); + + test("accepts a quantity without a unit", () => { + expect(accept(createItemSchema, { name: "Apples", quantity: 3 })).toEqual({ + name: "Apples", + quantity: 3, + }); + }); + + test("accepts a fractional quantity", () => { + expect(accept(createItemSchema, { name: "Mince", quantity: 0.5, unit: "kg" })).toEqual({ + name: "Mince", + quantity: 0.5, + unit: "kg", + }); + }); + + test("accepts a free-text note", () => { + expect(accept(createItemSchema, { name: "Milk", note: "the oat one" })).toEqual({ + name: "Milk", + note: "the oat one", + }); + }); + + test("trims the name, the unit and the note", () => { + expect( + accept(createItemSchema, { + name: " Milk ", + quantity: 2, + unit: " l ", + note: " the oat one ", + }), + ).toEqual({ name: "Milk", quantity: 2, unit: "l", note: "the oat one" }); + }); + + test("accepts a note at the maximum length", () => { + const note = "a".repeat(MAX_NOTE_LENGTH); + + expect(accept(createItemSchema, { name: "Milk", note })).toEqual({ name: "Milk", note }); + }); + + test("rejects a missing name", () => { + reject(createItemSchema, { quantity: 2, unit: "l" }); + }); + + test("rejects an empty or whitespace-only name", () => { + reject(createItemSchema, { name: "" }); + reject(createItemSchema, { name: " " }); + }); + + test("rejects a name over the maximum length", () => { + reject(createItemSchema, { name: "a".repeat(MAX_NAME_LENGTH + 1) }); + }); + + test("rejects a zero quantity", () => { + reject(createItemSchema, { name: "Milk", quantity: 0, unit: "l" }); + }); + + test("rejects a negative quantity", () => { + reject(createItemSchema, { name: "Milk", quantity: -1, unit: "l" }); + }); + + test("rejects a quantity that is not a finite number", () => { + reject(createItemSchema, { name: "Milk", quantity: Number.NaN }); + reject(createItemSchema, { name: "Milk", quantity: Number.POSITIVE_INFINITY }); + reject(createItemSchema, { name: "Milk", quantity: "2" }); + reject(createItemSchema, { name: "Milk", quantity: null }); + }); + + test("rejects a unit without a quantity — a unit measures nothing on its own", () => { + reject(createItemSchema, { name: "Mince", unit: "kg" }); + }); + + test("rejects an empty or whitespace-only unit", () => { + reject(createItemSchema, { name: "Milk", quantity: 2, unit: "" }); + reject(createItemSchema, { name: "Milk", quantity: 2, unit: " " }); + }); + + test("rejects a unit over the maximum length", () => { + reject(createItemSchema, { + name: "Milk", + quantity: 2, + unit: "a".repeat(MAX_UNIT_LENGTH + 1), + }); + }); + + test("rejects a unit that is not a string", () => { + reject(createItemSchema, { name: "Milk", quantity: 2, unit: 1 }); + reject(createItemSchema, { name: "Milk", quantity: 2, unit: null }); + }); + + test("rejects a note that is not a string, or is over the maximum length", () => { + reject(createItemSchema, { name: "Milk", note: 1 }); + reject(createItemSchema, { name: "Milk", note: null }); + reject(createItemSchema, { name: "Milk", note: "a".repeat(MAX_NOTE_LENGTH + 1) }); + }); + + test("rejects a Checked state — a new Item is never Checked", () => { + reject(createItemSchema, { name: "Milk", checked: false }); + reject(createItemSchema, { name: "Milk", checked: true }); + }); + + test("rejects a client-chosen id", () => { + reject(createItemSchema, { name: "Milk", id: "item-1" }); + }); +}); + +describe("updateItemSchema", () => { + test("accepts a new name alone", () => { + expect(accept(updateItemSchema, { name: "Oat milk" })).toEqual({ name: "Oat milk" }); + }); + + test("accepts a quantity with a unit", () => { + expect(accept(updateItemSchema, { quantity: 2, unit: "l" })).toEqual({ quantity: 2, unit: "l" }); + }); + + test("accepts a fractional quantity", () => { + expect(accept(updateItemSchema, { quantity: 1.5, unit: "kg" })).toEqual({ + quantity: 1.5, + unit: "kg", + }); + }); + + test("accepts a note alone", () => { + expect(accept(updateItemSchema, { note: "ripe ones" })).toEqual({ note: "ripe ones" }); + }); + + test("accepts null to clear the note", () => { + expect(accept(updateItemSchema, { note: null })).toEqual({ note: null }); + }); + + test("accepts null to clear the quantity and the unit together", () => { + expect(accept(updateItemSchema, { quantity: null, unit: null })).toEqual({ + quantity: null, + unit: null, + }); + }); + + test("accepts clearing the quantity alone", () => { + expect(accept(updateItemSchema, { quantity: null })).toEqual({ quantity: null }); + }); + + test("accepts clearing the unit while keeping a quantity", () => { + expect(accept(updateItemSchema, { quantity: 3, unit: null })).toEqual({ + quantity: 3, + unit: null, + }); + }); + + test("accepts every edited field at once", () => { + expect(accept(updateItemSchema, { name: "Mince", quantity: 0.5, unit: "kg", note: "lean" })).toEqual({ + name: "Mince", + quantity: 0.5, + unit: "kg", + note: "lean", + }); + }); + + test("trims the name, the unit and the note", () => { + expect(accept(updateItemSchema, { name: " Mince ", quantity: 1, unit: " kg " })).toEqual({ + name: "Mince", + quantity: 1, + unit: "kg", + }); + }); + + test("rejects an empty payload — an update must change something", () => { + reject(updateItemSchema, {}); + }); + + test("rejects a unit without a quantity in the same payload", () => { + reject(updateItemSchema, { unit: "kg" }); + reject(updateItemSchema, { name: "Mince", unit: "kg" }); + }); + + test("rejects setting a unit while clearing the quantity", () => { + reject(updateItemSchema, { quantity: null, unit: "kg" }); + }); + + test("rejects clearing the name — an Item always has one", () => { + reject(updateItemSchema, { name: null }); + reject(updateItemSchema, { name: "" }); + reject(updateItemSchema, { name: " " }); + }); + + test("rejects a zero, negative or non-finite quantity", () => { + reject(updateItemSchema, { quantity: 0, unit: "l" }); + reject(updateItemSchema, { quantity: -2, unit: "l" }); + reject(updateItemSchema, { quantity: Number.NaN }); + reject(updateItemSchema, { quantity: "2", unit: "l" }); + }); + + test("rejects an empty unit or one over the maximum length", () => { + reject(updateItemSchema, { quantity: 1, unit: "" }); + reject(updateItemSchema, { quantity: 1, unit: " " }); + reject(updateItemSchema, { quantity: 1, unit: "a".repeat(MAX_UNIT_LENGTH + 1) }); + }); + + test("rejects a note over the maximum length", () => { + reject(updateItemSchema, { note: "a".repeat(MAX_NOTE_LENGTH + 1) }); + }); + + test("rejects a Checked state — toggling Checked is its own endpoint", () => { + reject(updateItemSchema, { checked: true }); + reject(updateItemSchema, { name: "Milk", checked: false }); + }); +}); + +describe("setItemCheckedSchema", () => { + test("accepts Checked", () => { + expect(accept(setItemCheckedSchema, { checked: true })).toEqual({ checked: true }); + }); + + test("accepts un-Checked", () => { + expect(accept(setItemCheckedSchema, { checked: false })).toEqual({ checked: false }); + }); + + test("rejects a missing Checked state", () => { + reject(setItemCheckedSchema, {}); + }); + + test("rejects a Checked state that is not a boolean", () => { + reject(setItemCheckedSchema, { checked: "true" }); + reject(setItemCheckedSchema, { checked: 1 }); + reject(setItemCheckedSchema, { checked: null }); + }); + + test("rejects anything else alongside the Checked state", () => { + reject(setItemCheckedSchema, { checked: true, name: "Milk" }); + }); +}); + +describe("the bulk actions", () => { + test("clearing Checked Items takes an empty body", () => { + expect(accept(clearCheckedItemsSchema, {})).toEqual({}); + }); + + test("un-Checking every Item takes an empty body", () => { + expect(accept(uncheckAllItemsSchema, {})).toEqual({}); + }); + + test("neither takes a list of Items to act on — the action is the whole List", () => { + reject(clearCheckedItemsSchema, { itemIds: ["item-1"] }); + reject(uncheckAllItemsSchema, { itemIds: ["item-1"] }); + }); +}); From 66dd926a4389bacb5517e2fd7be18c11c36b4f4c Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:46:58 +0300 Subject: [PATCH 06/36] feat: implement can() over a constant Role map Owner and Editor are spelled out as separate Permission sets rather than Editor being derived from Owner, so adding a Permission forces a decision about Editor instead of silently widening it. An Ownerless List has no holder of the Owner-only Permissions and no fallback (ADR-0004). Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/permissions.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 095951d..701cec9 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -45,6 +45,38 @@ export type List = { readonly memberships: readonly Membership[]; }; -export function can(_account: Account, _permission: Permission, _list: List): boolean { - throw new Error("not implemented"); +/** + * The Role map. Owner holds every Permission; Editor holds all but the three + * that belong to Owner alone (CONTEXT.md, "Owner"). Both bundles are spelled + * out rather than derived from one another, so adding a Permission forces a + * decision about Editor here instead of silently widening it. + */ +const ROLE_PERMISSIONS: Record> = { + owner: new Set(PERMISSIONS), + editor: new Set([ + "list:read", + "list:update", + "item:create", + "item:update", + "item:delete", + "item:check", + "item:clear_checked", + "item:uncheck_all", + ]), +}; + +/** + * An Account is judged by its own Membership on this List and nothing else: a + * Membership on another List is not one here, and an Ownerless List simply has + * no holder of the Owner-only Permissions — there is no fallback (ADR-0004). + */ +function membershipOn(list: List, account: Account): Membership | undefined { + return list.memberships.find((membership) => membership.accountId === account.id && membership.listId === list.id); +} + +export function can(account: Account, permission: Permission, list: List): boolean { + const membership = membershipOn(list, account); + if (!membership) return false; + + return ROLE_PERMISSIONS[membership.role].has(permission); } From 6f37330c14112f6ee459ca7e197076c0065b6d49 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:46:58 +0300 Subject: [PATCH 07/36] feat: implement the REST surface schemas Every schema is strict, which is what keeps a `position` out of the wire format. A unit may not arrive without a quantity to measure, and an update must change something. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/schemas.ts | 56 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 38201ff..802d362 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -40,21 +40,55 @@ export type ClearCheckedItemsInput = Record; export type UncheckAllItemsInput = Record; -const notImplemented = (): z.ZodType => - z.any().transform((): T => { - throw new Error("not implemented"); - }) as unknown as z.ZodType; +/** Trimmed on the way in, so a name is never stored with edge whitespace. */ +const listName = z.string().trim().min(1).max(MAX_NAME_LENGTH); -export const createListSchema = notImplemented(); +const itemName = z.string().trim().min(1).max(MAX_NAME_LENGTH); -export const updateListSchema = notImplemented(); +const quantity = z.number().positive().finite(); -export const createItemSchema = notImplemented(); +const unit = z.string().trim().min(1).max(MAX_UNIT_LENGTH); -export const updateItemSchema = notImplemented(); +const note = z.string().trim().max(MAX_NOTE_LENGTH); -export const setItemCheckedSchema = notImplemented(); +/** A unit with nothing to measure is not a quantity (CONTEXT.md, "Item"). */ +function measuresAQuantity(input: { quantity?: number | null; unit?: string | null }): boolean { + if (input.unit === undefined || input.unit === null) return true; -export const clearCheckedItemsSchema = notImplemented(); + return typeof input.quantity === "number"; +} -export const uncheckAllItemsSchema = notImplemented(); +const UNIT_NEEDS_A_QUANTITY = { error: "a unit needs a quantity to measure", path: ["unit"] }; + +function changesSomething(input: object): boolean { + return Object.keys(input).length > 0; +} + +export const createListSchema: z.ZodType = z.strictObject({ name: listName }); + +export const updateListSchema: z.ZodType = z.strictObject({ name: listName }); + +export const createItemSchema: z.ZodType = z + .strictObject({ + name: itemName, + quantity: quantity.optional(), + unit: unit.optional(), + note: note.optional(), + }) + .refine(measuresAQuantity, UNIT_NEEDS_A_QUANTITY); + +export const updateItemSchema: z.ZodType = z + .strictObject({ + name: itemName.optional(), + quantity: quantity.nullable().optional(), + unit: unit.nullable().optional(), + note: note.nullable().optional(), + }) + .refine(changesSomething, { error: "an update must change something" }) + .refine(measuresAQuantity, UNIT_NEEDS_A_QUANTITY); + +export const setItemCheckedSchema: z.ZodType = z.strictObject({ checked: z.boolean() }); + +export const clearCheckedItemsSchema: z.ZodType = z.strictObject({}); + +export const uncheckAllItemsSchema: z.ZodType = z.strictObject({}); From bccb9f4be4bf78731ad0dc4c5edca266a32b0508 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:49:17 +0300 Subject: [PATCH 08/36] chore: add better-auth, kysely and kysely-postgres-js Better Auth runs on the application's single Bun.sql pool through kysely-postgres-js (ADR-0007). postgres.js stays out of the tree despite the dialect's peer dependency, verified in the Phase 0 spike. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 7d4ac1b..12ddab9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,9 @@ "": { "name": "bun-react-template", "dependencies": { + "better-auth": "^1.7.5", + "kysely": "^0.29.5", + "kysely-postgres-js": "^4.0.0", "react": "^19.3.0", "react-dom": "^19.3.0", "zod": "^4.6.5", @@ -57,6 +60,24 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@better-auth/core": ["@better-auth/core@1.7.5", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2" } }, "sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2" } }, "sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -95,6 +116,14 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -159,6 +188,10 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="], + "better-auth": ["better-auth@1.7.5", "", { "dependencies": { "@better-auth/core": "1.7.5", "@better-auth/drizzle-adapter": "1.7.5", "@better-auth/kysely-adapter": "1.7.5", "@better-auth/memory-adapter": "1.7.5", "@better-auth/mongo-adapter": "1.7.5", "@better-auth/prisma-adapter": "1.7.5", "@better-auth/telemetry": "1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA=="], + + "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], + "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], @@ -203,6 +236,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -377,6 +412,8 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -395,6 +432,10 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], + + "kysely-postgres-js": ["kysely-postgres-js@4.0.0", "", { "peerDependencies": { "kysely": ">= 0.29.0 < 1", "postgres": "^3.4.0" }, "optionalPeers": ["postgres"] }, "sha512-zIGYZ1TMduPZJAyeRMo9OodZYPxK4RI8OIKO8XShHiv5gzaY+KxmZnKcnYRvbfdmUH0kv+ZTlYrBZ5Uk+lI1QA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -411,6 +452,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanostores": ["nanostores@1.5.3", "", {}, "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="], @@ -473,6 +516,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -483,6 +528,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -577,6 +624,8 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/package.json b/package.json index 4a2229c..a031d55 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,13 @@ "start": "NODE_ENV=production bun src/index.ts", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "test:db": "docker compose -f docker-compose.test.yml up -d --wait" }, "dependencies": { + "better-auth": "^1.7.5", + "kysely": "^0.29.5", + "kysely-postgres-js": "^4.0.0", "react": "^19.3.0", "react-dom": "^19.3.0", "zod": "^4.6.5" From 457079a71de0ff206d9b0f5d399a0f4a84f941fe Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:49:17 +0300 Subject: [PATCH 09/36] test: migration runner, signing secret and PUBLIC_URL Tests come first and against a real Postgres, never a mock (CONVENTIONS.md). docker-compose.test.yml provides the server; each test takes its own database so a migration runner under test is free to create and drop whatever it likes. Co-Authored-By: Claude Opus 5 (1M context) --- docker-compose.test.yml | 21 ++++++++ src/auth/signing-secret.test.ts | 59 +++++++++++++++++++++ src/config.test.ts | 25 +++++++++ src/db/migrate.test.ts | 93 +++++++++++++++++++++++++++++++++ src/db/test-database.ts | 39 ++++++++++++++ 5 files changed, 237 insertions(+) create mode 100644 docker-compose.test.yml create mode 100644 src/auth/signing-secret.test.ts create mode 100644 src/config.test.ts create mode 100644 src/db/migrate.test.ts create mode 100644 src/db/test-database.ts diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..ca19eef --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,21 @@ +# Throwaway Postgres for `bun test`. Tests run against a real database, never a +# mock (CONVENTIONS.md, "Tests"). Port 55433 so it cannot collide with the +# postgres service in docker-compose.yml. +name: slist-test + +services: + postgres-test: + image: postgres:17-alpine + environment: + POSTGRES_USER: slist + POSTGRES_PASSWORD: slist + POSTGRES_DB: slist_test + ports: + - "55433:5432" + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U slist -d slist_test"] + interval: 2s + timeout: 3s + retries: 15 diff --git a/src/auth/signing-secret.test.ts b/src/auth/signing-secret.test.ts new file mode 100644 index 0000000..f97b3a1 --- /dev/null +++ b/src/auth/signing-secret.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { createTestDatabase, type TestDatabase } from "../db/test-database"; +import { runMigrations } from "../db/migrate"; +import { resolveSigningSecret } from "./signing-secret"; + +const MIGRATIONS = join(import.meta.dir, "../../migrations"); + +let db: TestDatabase; + +beforeEach(async () => { + db = await createTestDatabase(); + const migrated = await runMigrations(db.sql, MIGRATIONS); + if (!migrated.ok) throw new Error(`migrations failed: ${JSON.stringify(migrated.error)}`); +}); + +afterEach(async () => { + await db.drop(); +}); + +test("uses the secret from the environment and stores nothing", async () => { + const result = await resolveSigningSecret(db.sql, "a-secret-from-the-operator"); + + expect(result).toEqual({ ok: true, value: "a-secret-from-the-operator" }); + const rows = await db.sql`select count(*)::int as count from app_settings where key = 'auth.signing_secret'`; + expect(rows[0].count).toBe(0); +}); + +test("generates a secret on first boot and returns the same one on every later boot", async () => { + const first = await resolveSigningSecret(db.sql, undefined); + const second = await resolveSigningSecret(db.sql, undefined); + + expect(first.ok).toBe(true); + if (!first.ok) throw new Error("unreachable"); + expect(first.value.length).toBeGreaterThanOrEqual(32); + expect(second).toEqual({ ok: true, value: first.value }); +}); + +test("an env secret never overwrites the persisted one", async () => { + const generated = await resolveSigningSecret(db.sql, undefined); + if (!generated.ok) throw new Error("unreachable"); + + await resolveSigningSecret(db.sql, "an-env-secret"); + const afterEnvBoot = await resolveSigningSecret(db.sql, undefined); + + expect(afterEnvBoot).toEqual({ ok: true, value: generated.value }); +}); + +test("two instances on a first boot agree on one secret", async () => { + const other = new (await import("bun")).SQL(db.url); + + const [a, b] = await Promise.all([resolveSigningSecret(db.sql, undefined), resolveSigningSecret(other, undefined)]); + await other.end(); + + expect(a.ok && b.ok).toBe(true); + expect(a).toEqual(b); + const rows = await db.sql`select count(*)::int as count from app_settings where key = 'auth.signing_secret'`; + expect(rows[0].count).toBe(1); +}); diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..0e599c7 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { loadConfig, usesSecureCookies, websocketUrl } from "./config"; + +const env = { PUBLIC_URL: "https://lists.example.com", DATABASE_URL: "postgres://x/y" }; + +test("reads the public URL and database URL from the environment", () => { + const config = loadConfig(env); + + expect(config.publicUrl.origin).toBe("https://lists.example.com"); + expect(config.databaseUrl).toBe("postgres://x/y"); +}); + +test("refuses to start without a PUBLIC_URL", () => { + expect(() => loadConfig({ DATABASE_URL: "postgres://x/y" })).toThrow(/PUBLIC_URL is required/); +}); + +test("cookies are Secure only when the public URL is https", () => { + expect(usesSecureCookies(new URL("https://lists.example.com"))).toBe(true); + expect(usesSecureCookies(new URL("http://192.168.1.50:3000"))).toBe(false); +}); + +test("the WebSocket URL follows the public URL's scheme and host, not the request's", () => { + expect(websocketUrl(new URL("https://lists.example.com"), "/ws")).toBe("wss://lists.example.com/ws"); + expect(websocketUrl(new URL("http://192.168.1.50:3000"), "/ws")).toBe("ws://192.168.1.50:3000/ws"); +}); diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts new file mode 100644 index 0000000..36dfcfb --- /dev/null +++ b/src/db/migrate.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestDatabase, type TestDatabase } from "./test-database"; +import { runMigrations } from "./migrate"; + +let db: TestDatabase; +let dir: string; + +beforeEach(async () => { + db = await createTestDatabase(); + dir = await mkdtemp(join(tmpdir(), "slist-migrations-")); +}); + +afterEach(async () => { + await db.drop(); +}); + +async function writeMigration(name: string, sql: string): Promise { + await writeFile(join(dir, name), sql); +} + +async function appliedNames(database: TestDatabase): Promise { + const rows = await database.sql`select name from _migrations order by name`; + return rows.map((row: { name: string }) => row.name); +} + +test("applies numbered migrations in order and records them", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + await writeMigration("0002_second.sql", "alter table widgets add column label text;"); + + const result = await runMigrations(db.sql, dir); + + expect(result).toEqual({ ok: true, value: ["0001_first.sql", "0002_second.sql"] }); + expect(await appliedNames(db)).toEqual(["0001_first.sql", "0002_second.sql"]); + const columns = + await db.sql`select column_name from information_schema.columns where table_name = 'widgets' order by 1`; + expect(columns.map((c: { column_name: string }) => c.column_name)).toEqual(["id", "label"]); +}); + +test("a second boot applies nothing", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + await runMigrations(db.sql, dir); + + const second = await runMigrations(db.sql, dir); + + expect(second).toEqual({ ok: true, value: [] }); + expect(await appliedNames(db)).toEqual(["0001_first.sql"]); +}); + +test("a failing migration rolls back every statement in that file", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + await writeMigration( + "0002_broken.sql", + "create table gadgets (id text primary key); create table gadgets (id text primary key);", + ); + + const result = await runMigrations(db.sql, dir); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error.kind).toBe("migration_failed"); + expect(await appliedNames(db)).toEqual(["0001_first.sql"]); + const gadgets = await db.sql`select to_regclass('public.gadgets') as table`; + expect(gadgets[0].table).toBeNull(); +}); + +test("an already-applied migration that changed on disk stops the boot", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + await runMigrations(db.sql, dir); + await writeMigration("0001_first.sql", "create table widgets (id text primary key, sneaky text);"); + + const result = await runMigrations(db.sql, dir); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toEqual({ kind: "migration_changed", name: "0001_first.sql" }); +}); + +test("two instances booting at once apply each migration exactly once", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + await writeMigration("0002_second.sql", "create table gadgets (id text primary key);"); + const other = new (await import("bun")).SQL(db.url); + + const [a, b] = await Promise.all([runMigrations(db.sql, dir), runMigrations(other, dir)]); + await other.end(); + + expect(a.ok && b.ok).toBe(true); + const appliedByBoth = [...(a.ok ? a.value : []), ...(b.ok ? b.value : [])].sort(); + expect(appliedByBoth).toEqual(["0001_first.sql", "0002_second.sql"]); + expect(await appliedNames(db)).toEqual(["0001_first.sql", "0002_second.sql"]); +}); diff --git a/src/db/test-database.ts b/src/db/test-database.ts new file mode 100644 index 0000000..6899035 --- /dev/null +++ b/src/db/test-database.ts @@ -0,0 +1,39 @@ +import { SQL } from "bun"; +import { randomUUID } from "node:crypto"; + +/** + * Tests run against a real Postgres (CONVENTIONS.md, "Tests"). Each test gets its own + * database rather than a shared schema, so a migration runner under test can create and + * drop whatever it likes without seeing another test's tables. + * + * `docker compose -f docker-compose.test.yml up -d` provides the server. + */ +const ADMIN_URL = process.env.TEST_DATABASE_URL ?? "postgres://slist:slist@localhost:55433/slist_test"; + +export type TestDatabase = { + url: string; + sql: SQL; + drop: () => Promise; +}; + +export async function createTestDatabase(): Promise { + const name = `slist_test_${randomUUID().replaceAll("-", "")}`; + const admin = new SQL(ADMIN_URL); + await admin.unsafe(`create database "${name}"`); + await admin.end(); + + const url = new URL(ADMIN_URL); + url.pathname = `/${name}`; + const sql = new SQL(url.toString()); + + return { + url: url.toString(), + sql, + drop: async () => { + await sql.end(); + const cleanup = new SQL(ADMIN_URL); + await cleanup.unsafe(`drop database if exists "${name}" with (force)`); + await cleanup.end(); + }, + }; +} From c5d7d25aedf21bd4117d6d2bb8090b766d19ed72 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:49:18 +0300 Subject: [PATCH 10/36] feat: apply numbered SQL migrations on boot Each file runs in its own transaction and is recorded in _migrations with a checksum, so a half-applied file leaves no trace and an applied file that changed on disk stops the boot. A session advisory lock serialises two instances booting at once (ADR-0007, ADR-0008). Co-Authored-By: Claude Opus 5 (1M context) --- migrations/0001_app_settings.sql | 8 +++ src/db/migrate.ts | 116 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 migrations/0001_app_settings.sql create mode 100644 src/db/migrate.ts diff --git a/migrations/0001_app_settings.sql b/migrations/0001_app_settings.sql new file mode 100644 index 0000000..1044db2 --- /dev/null +++ b/migrations/0001_app_settings.sql @@ -0,0 +1,8 @@ +-- Server-side settings that must survive a restart but are not worth an env var. +-- Today that is only the auth signing secret, generated on first boot when the operator +-- did not supply one (ADR-0008). +create table app_settings ( + key text primary key, + value text not null, + created_at timestamptz not null default now() +); diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..02c31b9 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,116 @@ +import type { SQL } from "bun"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { err, ok, type Result } from "../lib/result"; + +/** + * Numbered `.sql` files applied on boot, each inside a transaction, tracked in `_migrations` + * (ADR-0007). No ORM and no generated diffs: the files are the schema, and a self-hoster whose + * boot fails can read the file that failed. + */ +export type MigrationError = + | { kind: "migrations_unreadable"; dir: string; cause: unknown } + | { kind: "migration_failed"; name: string; cause: unknown } + | { kind: "migration_changed"; name: string }; + +/** + * Two app instances booting at once would otherwise both see the same migration as pending and + * both try to apply it. A session-level advisory lock serialises them; the loser waits, then + * finds nothing to do. The key is an arbitrary constant, shared only with other copies of slist. + */ +const MIGRATION_LOCK_KEY = 4823551076; + +const CREATE_MIGRATIONS_TABLE = ` + create table if not exists _migrations ( + name text primary key, + checksum text not null, + applied_at timestamptz not null default now() + ) +`; + +type MigrationFile = { name: string; sql: string; checksum: string }; + +export async function runMigrations(sql: SQL, dir: string): Promise> { + const files = await readMigrationFiles(dir); + if (!files.ok) return files; + + const lock = await sql.reserve(); + try { + await lock`select pg_advisory_lock(${MIGRATION_LOCK_KEY})`; + // Created under the lock: concurrent `create table if not exists` for the same table can fail + // on Postgres' own catalogue unique index rather than being a no-op. + await lock.unsafe(CREATE_MIGRATIONS_TABLE); + + const applied = await readAppliedMigrations(lock); + const pending = files.value.filter((file) => !applied.has(file.name)); + + const unchanged = assertAppliedFilesUnchanged(files.value, applied); + if (!unchanged.ok) return unchanged; + + const appliedNow: string[] = []; + for (const file of pending) { + const result = await applyMigration(sql, file); + if (!result.ok) return result; + appliedNow.push(file.name); + } + return ok(appliedNow); + } finally { + await lock`select pg_advisory_unlock(${MIGRATION_LOCK_KEY})`; + lock.release(); + } +} + +async function readMigrationFiles(dir: string): Promise> { + let names: string[]; + try { + names = (await readdir(dir)).filter((name) => name.endsWith(".sql")).sort(); + } catch (cause) { + return err({ kind: "migrations_unreadable", dir, cause }); + } + + const files: MigrationFile[] = []; + for (const name of names) { + const text = await Bun.file(join(dir, name)).text(); + files.push({ name, sql: text, checksum: Bun.SHA256.hash(text, "hex") }); + } + return ok(files); +} + +async function readAppliedMigrations(sql: SQL): Promise> { + const rows = (await sql`select name, checksum from _migrations`) as { name: string; checksum: string }[]; + return new Map(rows.map((row) => [row.name, row.checksum])); +} + +/** + * An applied file that has changed on disk means the database and the repo disagree about what + * the schema is. Refusing to boot is the only honest answer: re-running it is not safe, and + * pretending it matches hides the drift until a much later query fails. + */ +function assertAppliedFilesUnchanged( + files: MigrationFile[], + applied: Map, +): Result { + for (const file of files) { + const checksum = applied.get(file.name); + if (checksum !== undefined && checksum !== file.checksum) { + return err({ kind: "migration_changed", name: file.name }); + } + } + return ok(null); +} + +/** + * The file's statements and the `_migrations` row commit together, so a migration that fails + * half way leaves no trace and the next boot retries it from the top. + */ +async function applyMigration(sql: SQL, file: MigrationFile): Promise> { + try { + await sql.begin(async (tx) => { + await tx.unsafe(file.sql); + await tx`insert into _migrations (name, checksum) values (${file.name}, ${file.checksum})`; + }); + return ok(null); + } catch (cause) { + return err({ kind: "migration_failed", name: file.name, cause }); + } +} From e9048a750f3d8c19fe4c8854e5b1a8f720fdd72e Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:49:27 +0300 Subject: [PATCH 11/36] feat: anonymous sessions on the shared Bun.sql pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A visitor is a real server-side Account from their first request (ADR-0003): GET / creates one and returns the session cookie. Better Auth shares the application's pool through kysely-postgres-js and its schema is CLI-generated as migration 0002 (ADR-0007). PUBLIC_URL is the only origin source (ADR-0008) — it sets the cookie's Secure flag and the WebSocket URL, and nothing reads the request Host header. The signing secret comes from the environment, or is generated on first boot and persisted so sessions survive a restart. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/0002_better_auth.sql | 20 ++++++ src/auth/auth-cli.ts | 16 +++++ src/auth/auth.ts | 42 ++++++++++++ src/auth/signing-secret.ts | 39 +++++++++++ src/config.ts | 43 ++++++++++++ src/index.ts | 113 ++++++++++++++++++++++++-------- 6 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 migrations/0002_better_auth.sql create mode 100644 src/auth/auth-cli.ts create mode 100644 src/auth/auth.ts create mode 100644 src/auth/signing-secret.ts create mode 100644 src/config.ts diff --git a/migrations/0002_better_auth.sql b/migrations/0002_better_auth.sql new file mode 100644 index 0000000..7e16fc2 --- /dev/null +++ b/migrations/0002_better_auth.sql @@ -0,0 +1,20 @@ +-- Better Auth's own tables. CLI-generated, not hand-written (ADR-0007): +-- DATABASE_URL=... bunx --bun @better-auth/cli generate \ +-- --config src/auth/auth-cli.ts --output migrations/0002_better_auth.sql -y +-- Regenerate against an empty database when the auth config gains a plugin, and commit the +-- result as a new numbered file rather than editing this one — an applied migration that +-- changes on disk stops the boot. +-- "user".isAnonymous comes from the Anonymous plugin (ADR-0003). +create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null, "isAnonymous" boolean); + +create table "session" ("id" text not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade); + +create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null); + +create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null); + +create index "session_userId_idx" on "session" ("userId"); + +create index "account_userId_idx" on "account" ("userId"); + +create index "verification_identifier_idx" on "verification" ("identifier"); \ No newline at end of file diff --git a/src/auth/auth-cli.ts b/src/auth/auth-cli.ts new file mode 100644 index 0000000..a9fdda2 --- /dev/null +++ b/src/auth/auth-cli.ts @@ -0,0 +1,16 @@ +import { SQL } from "bun"; +import { createAuth } from "./auth"; + +/** + * Config entry point for `@better-auth/cli generate` only — it introspects a live database and + * emits the SQL we commit as a migration (ADR-0007). Run it under Bun, since it imports `bun`: + * + * docker compose up -d postgres + * DATABASE_URL=... bunx --bun @better-auth/cli generate --config src/auth/auth-cli.ts \ + * --output migrations/0002_better_auth.sql -y + */ +export const auth = createAuth({ + sql: new SQL(process.env.DATABASE_URL!), + secret: "cli-only-secret", + publicUrl: new URL(process.env.PUBLIC_URL ?? "http://localhost:3000"), +}); diff --git a/src/auth/auth.ts b/src/auth/auth.ts new file mode 100644 index 0000000..9350f6a --- /dev/null +++ b/src/auth/auth.ts @@ -0,0 +1,42 @@ +import type { SQL } from "bun"; +import { betterAuth } from "better-auth"; +import { anonymous } from "better-auth/plugins"; +import { PostgresJSDialect } from "kysely-postgres-js"; +import { usesSecureCookies } from "../config"; + +export type Auth = ReturnType; + +export type AuthOptions = { + sql: SQL; + secret: string; + publicUrl: URL; +}; + +/** + * Better Auth runs on the application's single `Bun.sql` pool through `kysely-postgres-js`, which + * accepts a `Bun.SQL` where it expects a postgres.js client (ADR-0007). Verified in the Phase 0 + * spike: postgres.js stays out of the dependency tree despite the dialect's peer dependency. + * + * Better Auth checks the database schema on first use and caches the answer, so migrations must + * have finished before this instance handles a request — they do: they run before the server binds. + */ +export function createAuth({ sql, secret, publicUrl }: AuthOptions) { + return betterAuth({ + database: { + // `Bun.SQL` and postgres.js share a tagged-template interface; the cast is the price of the + // dialect typing its input as postgres.js. + dialect: new PostgresJSDialect({ postgres: sql as never }), + type: "postgres", + }, + secret, + baseURL: publicUrl.origin, + trustedOrigins: [publicUrl.origin], + advanced: { + // Derived from PUBLIC_URL, never from the request (ADR-0008). A LAN deploy on plain HTTP + // would drop a Secure cookie on the floor. + useSecureCookies: usesSecureCookies(publicUrl), + }, + // A visitor is a real Account from the first request (ADR-0003). + plugins: [anonymous()], + }); +} diff --git a/src/auth/signing-secret.ts b/src/auth/signing-secret.ts new file mode 100644 index 0000000..766191e --- /dev/null +++ b/src/auth/signing-secret.ts @@ -0,0 +1,39 @@ +import type { SQL } from "bun"; +import { randomBytes } from "node:crypto"; +import { err, ok, type Result } from "../lib/result"; + +/** + * The zero-config path has to be real: no secret in the environment must not mean a crash on + * first run (ADR-0008). The generated secret lives next to the session rows it protects, so it + * leaks nothing a database compromise would not already give up. + */ +export type SigningSecretError = { kind: "signing_secret_unavailable"; cause: unknown }; + +const SETTING_KEY = "auth.signing_secret"; + +export async function resolveSigningSecret( + sql: SQL, + envSecret: string | undefined, +): Promise> { + if (envSecret !== undefined && envSecret !== "") return ok(envSecret); + + try { + return ok(await generateAndPersistSecret(sql)); + } catch (cause) { + return err({ kind: "signing_secret_unavailable", cause }); + } +} + +/** + * `on conflict do nothing` plus a read-back makes this safe for two instances on a first boot: + * both may generate a candidate, exactly one row is written, and both return that row — so the + * secret is stable across restarts and across instances. + */ +async function generateAndPersistSecret(sql: SQL): Promise { + const candidate = randomBytes(32).toString("base64url"); + await sql`insert into app_settings (key, value) values (${SETTING_KEY}, ${candidate}) on conflict (key) do nothing`; + const rows = (await sql`select value from app_settings where key = ${SETTING_KEY}`) as { value: string }[]; + const stored = rows[0]?.value; + if (stored === undefined) throw new Error("app_settings row for the signing secret disappeared mid-boot"); + return stored; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f0777a4 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,43 @@ +/** + * `PUBLIC_URL` is the only origin source in the app (ADR-0008): the session cookie's `Secure` + * flag, the WebSocket URL and Invite link origins all derive from it. Nothing reads the request + * `Host` header — behind Caddy or Traefik it is a lie, and trusting it is how self-hosted apps + * hand people `http://localhost:3000/invite/...`. + */ +export type AppConfig = { + publicUrl: URL; + databaseUrl: string; + /** Set only by an operator who wants to control the secret; otherwise it is generated on first boot. */ + authSecret: string | undefined; + port: number; +}; + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + // A missing or malformed PUBLIC_URL is not something a caller can recover from: the app cannot + // build a correct cookie or link without it, so this throws rather than returning a Result. + const publicUrl = new URL(requireEnv(env, "PUBLIC_URL")); + return { + publicUrl, + databaseUrl: requireEnv(env, "DATABASE_URL"), + authSecret: env.AUTH_SECRET, + port: Number(env.PORT ?? 3000), + }; +} + +export function usesSecureCookies(publicUrl: URL): boolean { + return publicUrl.protocol === "https:"; +} + +export function websocketUrl(publicUrl: URL, path: string): string { + const url = new URL(path, publicUrl); + url.protocol = publicUrl.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function requireEnv(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]; + if (value === undefined || value === "") { + throw new Error(`${name} is required. Copy .env.example to .env and fill it in.`); + } + return value; +} diff --git a/src/index.ts b/src/index.ts index 863f069..c93a62f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,41 +1,102 @@ -import { serve } from "bun"; +import { SQL, serve } from "bun"; +import { join } from "node:path"; import index from "./index.html"; +import { loadConfig } from "./config"; +import { runMigrations } from "./db/migrate"; +import { createAuth, type Auth } from "./auth/auth"; +import { resolveSigningSecret } from "./auth/signing-secret"; +import { fromPromise } from "./lib/result"; + +const config = loadConfig(); + +// One pool for the application and for Better Auth (ADR-0007). +const sql = new SQL(config.databaseUrl); + +await migrateOrExit(); +const auth = createAuth({ sql, secret: await signingSecretOrExit(), publicUrl: config.publicUrl }); const server = serve({ + port: config.port, routes: { - // Serve index.html for all unmatched routes. + "/api/auth/*": (req) => auth.handler(req), + + // The bundled frontend. `/` goes through a handler instead so a first-time visitor leaves + // with a session cookie (ADR-0003), and a handler cannot return an HTMLBundle. + "/__shell": index, + "/": (req) => serveAppShell(req), "/*": index, "/api/hello": { - async GET(req) { - return Response.json({ - message: "Hello, world!", - method: "GET", - }); + async GET() { + return Response.json({ message: "Hello, world!", method: "GET" }); }, - async PUT(req) { - return Response.json({ - message: "Hello, world!", - method: "PUT", - }); + async PUT() { + return Response.json({ message: "Hello, world!", method: "PUT" }); }, }, - "/api/hello/:name": async req => { - const name = req.params.name; - return Response.json({ - message: `Hello, ${name}!`, - }); - }, + "/api/hello/:name": async (req) => Response.json({ message: `Hello, ${req.params.name}!` }), }, - development: process.env.NODE_ENV !== "production" && { - // Enable browser hot reloading in development - hmr: true, - - // Echo console logs from the browser to the server - console: true, - }, + development: process.env.NODE_ENV !== "production" && { hmr: true, console: true }, }); -console.log(`🚀 Server running at ${server.url}`); +console.log(`🚀 slist listening on ${server.url}, public URL ${config.publicUrl.origin}`); + +/** + * Migrations complete before the server binds its port, so a pulled image never serves requests + * against a half-upgraded schema (ADR-0008). A failure here is not a Result a caller can handle: + * the process has nothing useful left to do. + */ +async function migrateOrExit(): Promise { + const applied = await runMigrations(sql, join(import.meta.dir, "../migrations")); + if (!applied.ok) { + console.error("Migration failed, refusing to start:", applied.error); + process.exit(1); + } + console.log(applied.value.length > 0 ? `Applied migrations: ${applied.value.join(", ")}` : "Schema up to date"); +} + +async function signingSecretOrExit(): Promise { + const secret = await resolveSigningSecret(sql, config.authSecret); + if (!secret.ok) { + console.error("Could not resolve the auth signing secret:", secret.error); + process.exit(1); + } + return secret.value; +} + +/** + * Serves the frontend, creating an Anonymous Account for a visitor who has no session yet + * (ADR-0003). The page comes from this server's own `/__shell` route over loopback rather than + * off disk, so the bundler stays the one thing that knows how to build it — a route handler + * cannot return an HTMLBundle, which is why the bundle needs a route of its own. + */ +async function serveAppShell(req: Request): Promise { + const shell = await fetch(new URL("/__shell", server.url)); + const cookies = await sessionCookiesFor(req, auth); + if (cookies.length === 0) return shell; + + const response = new Response(shell.body, shell); + for (const cookie of cookies) response.headers.append("set-cookie", cookie); + return response; +} + +/** The cookies a visitor still needs: none if they already have a session. */ +async function sessionCookiesFor(req: Request, auth: Auth): Promise { + const existing = await fromPromise(auth.api.getSession({ headers: req.headers }), (cause) => cause); + if (existing.ok && existing.value !== null) return []; + + // `returnHeaders` hands back the Set-Cookie Better Auth would have written; its types do not + // model that overload, hence the cast. + const created = await fromPromise( + auth.api.signInAnonymous({ headers: req.headers, returnHeaders: true } as never) as Promise<{ headers: Headers }>, + (cause) => cause, + ); + if (!created.ok) { + // A visitor who cannot get an Account still gets the page; the frontend will retry. + console.error("Anonymous sign-in failed:", created.error); + return []; + } + return created.value.headers.getSetCookie(); +} From a3a99399921f328c8d9243194dcde9f5d7a87ad6 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:49:27 +0300 Subject: [PATCH 12/36] feat: docker compose deployment with app and postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy .env.example to .env and `docker compose up` gives a working app (ADR-0008): a pinned Postgres major on a named volume, a healthcheck gating the app's start, and migrations completing before the server binds. The image pins oven/bun:1.4 — pre-1.4 bun:sql could return one query's rows to another (oven-sh/bun#32772), which an auth adapter hits routinely. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 6 ++++++ .env.example | 23 +++++++++++++++++++++++ Dockerfile | 19 +++++++++++++++++++ docker-compose.yml | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4efac7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.env +*.log +docs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..435828d --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy to .env, then `docker compose up`. + +# The origin browsers use to reach this app — the only origin source in the app (ADR-0008). +# It sets the session cookie's Secure flag, the WebSocket URL and Invite link origins. +# Examples: http://localhost:3000 · http://192.168.1.50:3000 · https://lists.example.com +PUBLIC_URL=http://localhost:3000 + +# Where the app finds Postgres. docker-compose.yml overrides this with the compose-network +# address; it is here for running the app outside compose (`bun run dev`). +DATABASE_URL=postgres://slist:slist@localhost:5432/slist + +# Credentials for the bundled postgres service. Change the password for anything reachable +# beyond your own machine. +POSTGRES_USER=slist +POSTGRES_PASSWORD=slist +POSTGRES_DB=slist + +# Host port to publish the app on. +APP_PORT=3000 + +# Optional. Left empty, a signing secret is generated on first boot and persisted in the +# database, so sessions survive a restart (ADR-0008). +# AUTH_SECRET= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff0eefc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Bun 1.4 or later is required: pre-1.4 `bun:sql` could return one query's rows to another when a +# parameterless and a parameterised query shared a connection (oven-sh/bun#32772), which an auth +# adapter hits routinely (ADR-0007). +FROM oven/bun:1.4-alpine + +WORKDIR /app + +# Dependencies first, so a source change does not reinstall them. +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY . . + +ENV NODE_ENV=production +EXPOSE 3000 + +# One process serves the API, the WebSocket and the bundled frontend — same origin, no CORS +# (ADR-0008). Migrations run inside this process before it binds. +CMD ["bun", "src/index.ts"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..84b55fe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# `docker compose up` with a copied .env must produce a working app (ADR-0008). +services: + app: + build: . + env_file: .env + environment: + # The app reaches Postgres over the compose network, whatever the operator's .env says about + # reaching it from their laptop. + DATABASE_URL: postgres://${POSTGRES_USER:-slist}:${POSTGRES_PASSWORD:-slist}@postgres:5432/${POSTGRES_DB:-slist} + PORT: 3000 + ports: + - "${APP_PORT:-3000}:3000" + depends_on: + postgres: + # The app migrates on boot, so it must not start against a Postgres that is still + # initialising its data directory. + condition: service_healthy + restart: unless-stopped + + postgres: + # Major version pinned: a Postgres major upgrade needs an explicit data migration, never a + # surprise on `docker compose pull`. + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-slist} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-slist} + POSTGRES_DB: ${POSTGRES_DB:-slist} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-slist} -d ${POSTGRES_DB:-slist}"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + restart: unless-stopped + +volumes: + postgres-data: From ef005e735bf35e17fca3fc2ebe2421dc8adb3628 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:50:26 +0300 Subject: [PATCH 13/36] test: specify that an empty note means no note, red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI clears a note by emptying the text input and saving, so an empty or whitespace-only note is accepted and normalised, not rejected: to absent on create, to cleared on update. `null` stays valid on update as the explicit alternative. The assertions are on the parsed output, because the property that matters is that exactly one representation of "no note" reaches the database — two would push the ambiguity into the display and sort code. Not extended to `unit`: doing so would contradict two existing tests. Raised separately rather than settled here. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/schemas.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/lib/schemas.test.ts b/src/lib/schemas.test.ts index 3e5039c..6b98ed4 100644 --- a/src/lib/schemas.test.ts +++ b/src/lib/schemas.test.ts @@ -337,6 +337,100 @@ describe("updateItemSchema", () => { }); }); +/** + * "Empty means no note": the UI clears a note by emptying the text input and + * saving, which is what a text input naturally sends, so an empty or + * whitespace-only note is accepted and normalised rather than rejected. The + * property that matters is that exactly one representation of "no note" + * reaches the database, so the display and sort code never handles two. + */ +describe("createItemSchema, an empty note means no note", () => { + test("normalises an empty note to absent", () => { + const parsed = accept(createItemSchema, { name: "Milk", note: "" }); + + expect(Object.hasOwn(parsed, "note")).toBe(false); + expect(parsed).toEqual({ name: "Milk" }); + }); + + test("normalises a whitespace-only note to absent", () => { + for (const note of [" ", "\t\n"]) { + const parsed = accept(createItemSchema, { name: "Milk", note }); + + expect(Object.hasOwn(parsed, "note")).toBe(false); + expect(parsed).toEqual({ name: "Milk" }); + } + }); + + test("normalising the note does not disturb the other fields", () => { + const parsed = accept(createItemSchema, { name: "Mince", quantity: 0.5, unit: "kg", note: " " }); + + expect(Object.hasOwn(parsed, "note")).toBe(false); + expect(parsed).toEqual({ name: "Mince", quantity: 0.5, unit: "kg" }); + }); + + test("every spelling of no note parses to exactly one representation", () => { + const spellings = [ + { name: "Milk" }, + { name: "Milk", note: "" }, + { name: "Milk", note: " " }, + { name: "Milk", note: "\t\n" }, + ]; + + const parsed = spellings.map((input) => accept(createItemSchema, input)); + const representations = new Set(parsed.map((result) => JSON.stringify(result))); + + for (const result of parsed) { + expect(Object.keys(result).sort()).toEqual(["name"]); + } + expect(representations.size).toBe(1); + }); +}); + +describe("updateItemSchema, an empty note means no note", () => { + test("normalises an empty note to a cleared note", () => { + const parsed = accept(updateItemSchema, { note: "" }); + + expect(parsed.note).toBe(null); + expect(parsed).toEqual({ note: null }); + }); + + test("normalises a whitespace-only note to a cleared note", () => { + for (const note of [" ", "\t\n"]) { + expect(accept(updateItemSchema, { note }).note).toBe(null); + } + }); + + test("an emptied note and an explicit null are the same instruction", () => { + const viaEmpty = accept(updateItemSchema, { note: "" }); + const viaWhitespace = accept(updateItemSchema, { note: " " }); + const viaNull = accept(updateItemSchema, { note: null }); + + expect(viaEmpty).toEqual(viaNull); + expect(viaWhitespace).toEqual(viaNull); + expect(Object.keys(viaEmpty).sort()).toEqual(Object.keys(viaNull).sort()); + }); + + test("clearing the note alongside other edits is still one payload", () => { + const parsed = accept(updateItemSchema, { name: "Oat milk", note: "" }); + + expect(parsed).toEqual({ name: "Oat milk", note: null }); + }); + + test("an emptied note on its own still counts as a change", () => { + expect(accept(updateItemSchema, { note: " " })).toEqual({ note: null }); + }); + + test("every spelling of no note parses to exactly one representation", () => { + const spellings = [{ note: null }, { note: "" }, { note: " " }, { note: "\t\n" }]; + + const parsed = spellings.map((input) => accept(updateItemSchema, input)); + const representations = new Set(parsed.map((result) => JSON.stringify(result))); + + expect(representations.size).toBe(1); + expect([...representations]).toEqual(['{"note":null}']); + }); +}); + describe("setItemCheckedSchema", () => { test("accepts Checked", () => { expect(accept(setItemCheckedSchema, { checked: true })).toEqual({ checked: true }); From ebb1d9930f6b0ab9f28c7e42a215806d9d80c7c5 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:55:40 +0300 Subject: [PATCH 14/36] test: overturn the cases the empty-means-nothing ruling contradicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that an empty note means no note now covers `unit`, and on create `null` joins `""` as a spelling of nothing. Four cases asserted the opposite and are changed here, on their own, before any test that depends on the new behaviour is written. Deleted: - createItemSchema > rejects an empty or whitespace-only unit — an emptied unit input is now accepted and normalised, not a 400. - the two empty-unit assertions in updateItemSchema > rejects an empty unit or one over the maximum length, which is now "rejects a unit over the maximum length". The over-length half still rejects. Relaxed: - createItemSchema > rejects a unit that is not a string — `unit: null` moved from reject to accept; `true` covers the not-a-string case in its place. - createItemSchema > rejects a note that is not a string, or is over the maximum length — `note: null` likewise. `quantity` is untouched: it has no empty-string spelling to collapse, and `null` still clears it on update and still rejects on create. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/schemas.test.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/lib/schemas.test.ts b/src/lib/schemas.test.ts index 6b98ed4..ed051bd 100644 --- a/src/lib/schemas.test.ts +++ b/src/lib/schemas.test.ts @@ -202,11 +202,6 @@ describe("createItemSchema", () => { reject(createItemSchema, { name: "Mince", unit: "kg" }); }); - test("rejects an empty or whitespace-only unit", () => { - reject(createItemSchema, { name: "Milk", quantity: 2, unit: "" }); - reject(createItemSchema, { name: "Milk", quantity: 2, unit: " " }); - }); - test("rejects a unit over the maximum length", () => { reject(createItemSchema, { name: "Milk", @@ -217,12 +212,12 @@ describe("createItemSchema", () => { test("rejects a unit that is not a string", () => { reject(createItemSchema, { name: "Milk", quantity: 2, unit: 1 }); - reject(createItemSchema, { name: "Milk", quantity: 2, unit: null }); + reject(createItemSchema, { name: "Milk", quantity: 2, unit: true }); }); test("rejects a note that is not a string, or is over the maximum length", () => { reject(createItemSchema, { name: "Milk", note: 1 }); - reject(createItemSchema, { name: "Milk", note: null }); + reject(createItemSchema, { name: "Milk", note: true }); reject(createItemSchema, { name: "Milk", note: "a".repeat(MAX_NOTE_LENGTH + 1) }); }); @@ -321,9 +316,7 @@ describe("updateItemSchema", () => { reject(updateItemSchema, { quantity: "2", unit: "l" }); }); - test("rejects an empty unit or one over the maximum length", () => { - reject(updateItemSchema, { quantity: 1, unit: "" }); - reject(updateItemSchema, { quantity: 1, unit: " " }); + test("rejects a unit over the maximum length", () => { reject(updateItemSchema, { quantity: 1, unit: "a".repeat(MAX_UNIT_LENGTH + 1) }); }); From 21be97628c8aab56059677379975d5e8725ed6b3 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:57:15 +0300 Subject: [PATCH 15/36] test: specify that an empty unit means no unit, red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the note rule over `unit`, and adds `null` as a create-side spelling of nothing for both fields, so one payload shape serves create and update. The coupling rule makes `unit` sharper than `note`: an empty unit must be normalised away BEFORE "a unit needs a quantity" is checked. `{name, unit: ""}` — rename an Item and clear its unit in one save — asserts that ordering, and is not satisfied by dropping the `.min(1)` alone; with the normalisation after the refine it still fails on the coupling error. `{quantity, unit: ""}` isolates the normalisation, since that one passes the coupling check either way, so the two together say which half is wrong. `{quantity: null, unit: ""}` clears both. `{quantity: null, unit: "kg"}` still rejects: clearing a quantity while naming a real unit is incoherent, emptying both fields is not. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/schemas.test.ts | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/lib/schemas.test.ts b/src/lib/schemas.test.ts index ed051bd..733b5c2 100644 --- a/src/lib/schemas.test.ts +++ b/src/lib/schemas.test.ts @@ -354,6 +354,13 @@ describe("createItemSchema, an empty note means no note", () => { } }); + test("normalises a null note to absent", () => { + const parsed = accept(createItemSchema, { name: "Milk", note: null }); + + expect(Object.hasOwn(parsed, "note")).toBe(false); + expect(parsed).toEqual({ name: "Milk" }); + }); + test("normalising the note does not disturb the other fields", () => { const parsed = accept(createItemSchema, { name: "Mince", quantity: 0.5, unit: "kg", note: " " }); @@ -367,6 +374,7 @@ describe("createItemSchema, an empty note means no note", () => { { name: "Milk", note: "" }, { name: "Milk", note: " " }, { name: "Milk", note: "\t\n" }, + { name: "Milk", note: null }, ]; const parsed = spellings.map((input) => accept(createItemSchema, input)); @@ -424,6 +432,122 @@ describe("updateItemSchema, an empty note means no note", () => { }); }); +/** + * The same ruling over `unit`: an emptied unit input is a cleared unit, not a + * 400. `unit` is sharper than `note` because of the coupling rule — an empty + * unit must be normalised away BEFORE "a unit needs a quantity" is checked, or + * renaming an Item while clearing its unit stays impossible from the obvious + * client behaviour. + */ +describe("createItemSchema, an empty unit means no unit", () => { + test("normalises an empty unit to absent", () => { + const parsed = accept(createItemSchema, { name: "Milk", quantity: 2, unit: "" }); + + expect(Object.hasOwn(parsed, "unit")).toBe(false); + expect(parsed).toEqual({ name: "Milk", quantity: 2 }); + }); + + test("normalises a whitespace-only unit to absent", () => { + for (const unit of [" ", "\t\n"]) { + const parsed = accept(createItemSchema, { name: "Milk", quantity: 2, unit }); + + expect(Object.hasOwn(parsed, "unit")).toBe(false); + expect(parsed).toEqual({ name: "Milk", quantity: 2 }); + } + }); + + test("normalises a null unit to absent", () => { + const parsed = accept(createItemSchema, { name: "Milk", quantity: 2, unit: null }); + + expect(Object.hasOwn(parsed, "unit")).toBe(false); + expect(parsed).toEqual({ name: "Milk", quantity: 2 }); + }); + + test("an empty unit with no quantity is nothing to couple, so it is accepted", () => { + const parsed = accept(createItemSchema, { name: "Mince", unit: "" }); + + expect(Object.hasOwn(parsed, "unit")).toBe(false); + expect(parsed).toEqual({ name: "Mince" }); + }); + + test("a real unit with no quantity still rejects — the coupling rule is not weakened", () => { + reject(createItemSchema, { name: "Mince", unit: "kg" }); + }); + + test("every spelling of no unit parses to exactly one representation", () => { + const spellings = [ + { name: "Milk", quantity: 2 }, + { name: "Milk", quantity: 2, unit: "" }, + { name: "Milk", quantity: 2, unit: " " }, + { name: "Milk", quantity: 2, unit: "\t\n" }, + { name: "Milk", quantity: 2, unit: null }, + ]; + + const parsed = spellings.map((input) => accept(createItemSchema, input)); + const representations = new Set(parsed.map((result) => JSON.stringify(result))); + + for (const result of parsed) { + expect(Object.keys(result).sort()).toEqual(["name", "quantity"]); + } + expect(representations.size).toBe(1); + }); +}); + +describe("updateItemSchema, an empty unit means no unit", () => { + test("clears the unit and keeps the quantity", () => { + expect(accept(updateItemSchema, { quantity: 3, unit: "" })).toEqual({ + quantity: 3, + unit: null, + }); + }); + + test("normalises a whitespace-only unit to a cleared unit", () => { + for (const unit of [" ", "\t\n"]) { + expect(accept(updateItemSchema, { quantity: 3, unit }).unit).toBe(null); + } + }); + + test("clears the unit on its own", () => { + expect(accept(updateItemSchema, { unit: "" })).toEqual({ unit: null }); + }); + + test("renames the Item and clears its unit in one save", () => { + expect(accept(updateItemSchema, { name: "Mince", unit: "" })).toEqual({ + name: "Mince", + unit: null, + }); + }); + + test("clears the quantity and the unit together when the unit is emptied", () => { + expect(accept(updateItemSchema, { quantity: null, unit: "" })).toEqual({ + quantity: null, + unit: null, + }); + }); + + test("still rejects a real unit alongside a cleared quantity", () => { + reject(updateItemSchema, { quantity: null, unit: "kg" }); + }); + + test("an emptied unit and an explicit null are the same instruction", () => { + const viaEmpty = accept(updateItemSchema, { unit: "" }); + const viaNull = accept(updateItemSchema, { unit: null }); + + expect(viaEmpty).toEqual(viaNull); + expect(Object.keys(viaEmpty).sort()).toEqual(Object.keys(viaNull).sort()); + }); + + test("every spelling of no unit parses to exactly one representation", () => { + const spellings = [{ unit: null }, { unit: "" }, { unit: " " }, { unit: "\t\n" }]; + + const parsed = spellings.map((input) => accept(updateItemSchema, input)); + const representations = new Set(parsed.map((result) => JSON.stringify(result))); + + expect(representations.size).toBe(1); + expect([...representations]).toEqual(['{"unit":null}']); + }); +}); + describe("setItemCheckedSchema", () => { test("accepts Checked", () => { expect(accept(setItemCheckedSchema, { checked: true })).toEqual({ checked: true }); From 4935db0f857bac7231ef6c4c148f041c47ff08d3 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:57:28 +0300 Subject: [PATCH 16/36] feat: add lists, items and memberships tables No `position` column on items: Items have no inherent order and the server never orders them, so there is nothing for a column to hold. The database refuses a unit with no quantity, the same rule the Zod schemas apply at the edge, and an Ownerless List is a valid state (ADR-0004). Co-Authored-By: Claude Opus 5 (1M context) --- migrations/0003_lists_items_memberships.sql | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 migrations/0003_lists_items_memberships.sql diff --git a/migrations/0003_lists_items_memberships.sql b/migrations/0003_lists_items_memberships.sql new file mode 100644 index 0000000..5a77f33 --- /dev/null +++ b/migrations/0003_lists_items_memberships.sql @@ -0,0 +1,45 @@ +-- Lists, their Memberships and their Items (CONTEXT.md). +-- +-- There is deliberately no `position` column on items: Items have no inherent order, the server +-- never orders them, and sorting is the client's job. A column would be an invitation to start. +-- +-- `memberships.role` is only ever read by `can()` (ADR-0005); the check constraint keeps the +-- column honest about the Roles that exist in code. + +create table lists ( + id uuid primary key default gen_random_uuid(), + name text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table memberships ( + list_id uuid not null references lists (id) on delete cascade, + account_id text not null references "user" (id) on delete cascade, + role text not null check (role in ('owner', 'editor')), + created_at timestamptz not null default now(), + primary key (list_id, account_id) +); + +-- A List whose Owners have all gone keeps working (ADR-0004), so nothing here requires an Owner +-- to exist: an Ownerless List is a valid state, not a broken one. +create index memberships_account_id_idx on memberships (account_id); + +create table items ( + id uuid primary key default gen_random_uuid(), + list_id uuid not null references lists (id) on delete cascade, + name text not null, + -- Shopping quantities are approximate ("0.5 kg"), so a float is the right shape; nothing here + -- is money, where the rounding would matter. + quantity double precision check (quantity > 0), + unit text, + note text, + checked boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + -- A unit with nothing to measure is not a quantity; the Zod schemas say the same thing at the + -- edge, and the database refuses to hold the state they reject. + constraint items_unit_needs_quantity check (unit is null or quantity is not null) +); + +create index items_list_id_idx on items (list_id); From 39abc660fa3259b27f77fb5c75e8577e74c3b474 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:57:28 +0300 Subject: [PATCH 17/36] test: specify the REST surface against a real Postgres, red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each endpoint has a happy path and a denied permission. Denial has two shapes: an Account with no Membership is told 404 because it cannot see the List at all, a Member lacking the Permission is told 403. The domain functions are signature stubs, so the red is behavioural — 404 where a 201 belongs — rather than a missing import. Co-Authored-By: Claude Opus 5 (1M context) --- src/api/api.test.ts | 426 +++++++++++++++++++++++++++++++++++++++++ src/api/http.ts | 62 ++++++ src/api/routes.ts | 167 ++++++++++++++++ src/api/session.ts | 15 ++ src/api/test-server.ts | 59 ++++++ src/items/items.ts | 62 ++++++ src/lib/errors.ts | 19 ++ src/lists/lists.ts | 50 +++++ 8 files changed, 860 insertions(+) create mode 100644 src/api/api.test.ts create mode 100644 src/api/http.ts create mode 100644 src/api/routes.ts create mode 100644 src/api/session.ts create mode 100644 src/api/test-server.ts create mode 100644 src/items/items.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lists/lists.ts diff --git a/src/api/api.test.ts b/src/api/api.test.ts new file mode 100644 index 0000000..92ae41f --- /dev/null +++ b/src/api/api.test.ts @@ -0,0 +1,426 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createTestServer, type TestActor, type TestServer } from "./test-server"; + +/** + * Every endpoint has a happy path and a denied permission, against a real Postgres + * (CONVENTIONS.md, "Tests"). Denial has two shapes: an Account with no Membership cannot see the + * List at all, so it is told 404, while a Member who lacks the Permission is told 403. + */ +let server: TestServer; +let owner: TestActor; +let stranger: TestActor; + +beforeAll(async () => { + server = await createTestServer(); + owner = await server.signIn(); + stranger = await server.signIn(); +}); + +afterAll(async () => { + await server.stop(); +}); + +type Json = Record; + +/** Bun's tagged template is untyped; the shape a test asserts on is the test's business. */ +async function rows(query: unknown): Promise { + return (await query) as T[]; +} + +async function call(method: string, path: string, actor: TestActor | null, body?: unknown): Promise { + return fetch(`${server.url}${path}`, { + method, + headers: { + ...(actor ? { cookie: actor.cookie } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +async function json(response: Response): Promise { + return (await response.json()) as Json; +} + +async function createList(actor: TestActor, name = "Groceries"): Promise { + const response = await call("POST", "/api/lists", actor, { name }); + expect(response.status).toBe(201); + return (await json(response)).id as string; +} + +async function createItem(actor: TestActor, listId: string, body: Json): Promise { + const response = await call("POST", `/api/lists/${listId}/items`, actor, body); + expect(response.status).toBe(201); + return json(response); +} + +/** An Editor Membership, the Role an Invite will grant in a later phase. */ +async function addEditor(listId: string): Promise { + const editor = await server.signIn(); + await rows( + server.sql`insert into memberships (list_id, account_id, role) values (${listId}, ${editor.accountId}, 'editor')`, + ); + return editor; +} + +describe("POST /api/lists", () => { + test("creates a List and an Owner Membership for the creator", async () => { + const response = await call("POST", "/api/lists", owner, { name: " Groceries " }); + + expect(response.status).toBe(201); + const created = await json(response); + expect(created.name).toBe("Groceries"); + const memberships = await rows(server.sql`select account_id, role from memberships where list_id = ${created.id}`); + expect(memberships).toEqual([{ account_id: owner.accountId, role: "owner" }]); + }); + + test("rejects a nameless List", async () => { + const response = await call("POST", "/api/lists", owner, { name: " " }); + + expect(response.status).toBe(400); + }); + + test("rejects a visitor with no session", async () => { + const response = await call("POST", "/api/lists", null, { name: "Groceries" }); + + expect(response.status).toBe(401); + }); +}); + +describe("GET /api/lists", () => { + test("returns the Lists the Account is a Member of", async () => { + const mine = await server.signIn(); + const listId = await createList(mine, "Mine"); + + const response = await call("GET", "/api/lists", mine); + + expect(response.status).toBe(200); + const body = await json(response); + expect((body.lists as Json[]).map((list) => list.id)).toEqual([listId]); + }); + + test("shows nothing of another Account's Lists", async () => { + await createList(owner, "Private"); + + const response = await call("GET", "/api/lists", stranger); + + expect(response.status).toBe(200); + expect((await json(response)).lists).toEqual([]); + }); +}); + +describe("GET /api/lists/:listId", () => { + test("returns the List with its Items", async () => { + const listId = await createList(owner, "Weekly shop"); + await createItem(owner, listId, { name: "Milk", quantity: 2, unit: "l" }); + + const response = await call("GET", `/api/lists/${listId}`, owner); + + expect(response.status).toBe(200); + const body = await json(response); + expect((body.list as Json).name).toBe("Weekly shop"); + const items = body.items as Json[]; + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ name: "Milk", quantity: 2, unit: "l", checked: false }); + }); + + test("an Account with no Membership cannot see it at all", async () => { + const listId = await createList(owner); + + const response = await call("GET", `/api/lists/${listId}`, stranger); + + expect(response.status).toBe(404); + }); + + test("rejects a listId that is not an id", async () => { + const response = await call("GET", "/api/lists/not-an-id", owner); + + expect(response.status).toBe(400); + }); +}); + +describe("PATCH /api/lists/:listId", () => { + test("renames the List", async () => { + const listId = await createList(owner); + + const response = await call("PATCH", `/api/lists/${listId}`, owner, { name: " Weekly shop " }); + + expect(response.status).toBe(200); + expect((await json(response)).name).toBe("Weekly shop"); + }); + + test("an Editor may rename the List", async () => { + const listId = await createList(owner); + const editor = await addEditor(listId); + + const response = await call("PATCH", `/api/lists/${listId}`, editor, { name: "Editor's name" }); + + expect(response.status).toBe(200); + }); + + test("an Account with no Membership cannot rename it", async () => { + const listId = await createList(owner); + + const response = await call("PATCH", `/api/lists/${listId}`, stranger, { name: "Theirs" }); + + expect(response.status).toBe(404); + }); +}); + +describe("DELETE /api/lists/:listId", () => { + test("deletes the List and its Items", async () => { + const listId = await createList(owner); + await createItem(owner, listId, { name: "Milk" }); + + const response = await call("DELETE", `/api/lists/${listId}`, owner); + + expect(response.status).toBe(204); + expect(await rows(server.sql`select id from lists where id = ${listId}`)).toEqual([]); + expect(await rows(server.sql`select id from items where list_id = ${listId}`)).toEqual([]); + }); + + test("an Editor may not delete the List", async () => { + const listId = await createList(owner); + const editor = await addEditor(listId); + + const response = await call("DELETE", `/api/lists/${listId}`, editor); + + expect(response.status).toBe(403); + expect(await rows(server.sql`select id from lists where id = ${listId}`)).toHaveLength(1); + }); +}); + +describe("POST /api/lists/:listId/items", () => { + test("adds an Item with a quantity and a unit", async () => { + const listId = await createList(owner); + + const response = await call("POST", `/api/lists/${listId}/items`, owner, { + name: " Mince ", + quantity: 0.5, + unit: "kg", + note: "lean", + }); + + expect(response.status).toBe(201); + expect(await json(response)).toMatchObject({ + name: "Mince", + quantity: 0.5, + unit: "kg", + note: "lean", + checked: false, + }); + }); + + test("adds an Item with no quantity at all", async () => { + const listId = await createList(owner); + + const created = await createItem(owner, listId, { name: "Milk" }); + + expect(created).toMatchObject({ name: "Milk", quantity: null, unit: null, note: null }); + }); + + test("rejects a unit with nothing to measure", async () => { + const listId = await createList(owner); + + const response = await call("POST", `/api/lists/${listId}/items`, owner, { name: "Mince", unit: "kg" }); + + expect(response.status).toBe(400); + }); + + test("an Account with no Membership may not add an Item", async () => { + const listId = await createList(owner); + + const response = await call("POST", `/api/lists/${listId}/items`, stranger, { name: "Milk" }); + + expect(response.status).toBe(404); + expect(await rows(server.sql`select id from items where list_id = ${listId}`)).toEqual([]); + }); +}); + +describe("PATCH /api/lists/:listId/items/:itemId", () => { + test("edits the name, the quantity, the unit and the note", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("PATCH", `/api/lists/${listId}/items/${item.id}`, owner, { + name: "Oat milk", + quantity: 2, + unit: "l", + note: "the barista one", + }); + + expect(response.status).toBe(200); + expect(await json(response)).toMatchObject({ + name: "Oat milk", + quantity: 2, + unit: "l", + note: "the barista one", + }); + }); + + test("clears the note and the quantity with null", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk", quantity: 2, unit: "l", note: "oat" }); + + const response = await call("PATCH", `/api/lists/${listId}/items/${item.id}`, owner, { + quantity: null, + unit: null, + note: null, + }); + + expect(response.status).toBe(200); + expect(await json(response)).toMatchObject({ name: "Milk", quantity: null, unit: null, note: null }); + }); + + test("leaves the fields the payload does not mention alone", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk", quantity: 2, unit: "l", note: "oat" }); + + const response = await call("PATCH", `/api/lists/${listId}/items/${item.id}`, owner, { name: "Oat milk" }); + + expect(await json(response)).toMatchObject({ name: "Oat milk", quantity: 2, unit: "l", note: "oat" }); + }); + + test("refuses to toggle Checked — that is its own endpoint", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("PATCH", `/api/lists/${listId}/items/${item.id}`, owner, { checked: true }); + + expect(response.status).toBe(400); + }); + + test("an Item on another List is not found", async () => { + const listId = await createList(owner); + const otherListId = await createList(owner, "Other"); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("PATCH", `/api/lists/${otherListId}/items/${item.id}`, owner, { name: "Moved" }); + + expect(response.status).toBe(404); + }); + + test("an Account with no Membership may not edit an Item", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("PATCH", `/api/lists/${listId}/items/${item.id}`, stranger, { name: "Theirs" }); + + expect(response.status).toBe(404); + }); +}); + +describe("DELETE /api/lists/:listId/items/:itemId", () => { + test("removes the Item", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("DELETE", `/api/lists/${listId}/items/${item.id}`, owner); + + expect(response.status).toBe(204); + expect(await rows(server.sql`select id from items where id = ${item.id}`)).toEqual([]); + }); + + test("an Account with no Membership may not remove an Item", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("DELETE", `/api/lists/${listId}/items/${item.id}`, stranger); + + expect(response.status).toBe(404); + expect(await rows(server.sql`select id from items where id = ${item.id}`)).toHaveLength(1); + }); +}); + +describe("PUT /api/lists/:listId/items/:itemId/checked", () => { + test("Checks an Item and un-Checks it again, leaving it on the List", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const checked = await call("PUT", `/api/lists/${listId}/items/${item.id}/checked`, owner, { checked: true }); + expect(checked.status).toBe(200); + expect(await json(checked)).toMatchObject({ id: item.id, checked: true }); + + const unchecked = await call("PUT", `/api/lists/${listId}/items/${item.id}/checked`, owner, { checked: false }); + expect(await json(unchecked)).toMatchObject({ id: item.id, checked: false }); + }); + + test("rejects a Checked state that is not a boolean", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("PUT", `/api/lists/${listId}/items/${item.id}/checked`, owner, { checked: "yes" }); + + expect(response.status).toBe(400); + }); + + test("an Account with no Membership may not Check an Item", async () => { + const listId = await createList(owner); + const item = await createItem(owner, listId, { name: "Milk" }); + + const response = await call("PUT", `/api/lists/${listId}/items/${item.id}/checked`, stranger, { checked: true }); + + expect(response.status).toBe(404); + }); +}); + +describe("POST /api/lists/:listId/clear-checked", () => { + test("removes the Checked Items and leaves the rest", async () => { + const listId = await createList(owner); + const milk = await createItem(owner, listId, { name: "Milk" }); + const bread = await createItem(owner, listId, { name: "Bread" }); + await call("PUT", `/api/lists/${listId}/items/${milk.id}/checked`, owner, { checked: true }); + + const response = await call("POST", `/api/lists/${listId}/clear-checked`, owner, {}); + + expect(response.status).toBe(200); + expect(await json(response)).toEqual({ removed: 1 }); + const remaining = await rows(server.sql`select id from items where list_id = ${listId}`); + expect(remaining).toEqual([{ id: bread.id }]); + }); + + test("an Account with no Membership may not clear a List", async () => { + const listId = await createList(owner); + await createItem(owner, listId, { name: "Milk" }); + + const response = await call("POST", `/api/lists/${listId}/clear-checked`, stranger, {}); + + expect(response.status).toBe(404); + expect(await rows(server.sql`select id from items where list_id = ${listId}`)).toHaveLength(1); + }); +}); + +describe("POST /api/lists/:listId/uncheck-all", () => { + test("un-Checks every Checked Item", async () => { + const listId = await createList(owner); + const milk = await createItem(owner, listId, { name: "Milk" }); + await createItem(owner, listId, { name: "Bread" }); + await call("PUT", `/api/lists/${listId}/items/${milk.id}/checked`, owner, { checked: true }); + + const response = await call("POST", `/api/lists/${listId}/uncheck-all`, owner, {}); + + expect(response.status).toBe(200); + expect(await json(response)).toEqual({ unchecked: 1 }); + const checked = await rows(server.sql`select id from items where list_id = ${listId} and checked`); + expect(checked).toEqual([]); + }); + + test("rejects a body naming the Items to act on — the action is the whole List", async () => { + const listId = await createList(owner); + + const response = await call("POST", `/api/lists/${listId}/uncheck-all`, owner, { itemIds: ["item-1"] }); + + expect(response.status).toBe(400); + }); + + test("an Account with no Membership may not un-Check a List", async () => { + const listId = await createList(owner); + const milk = await createItem(owner, listId, { name: "Milk" }); + await call("PUT", `/api/lists/${listId}/items/${milk.id}/checked`, owner, { checked: true }); + + const response = await call("POST", `/api/lists/${listId}/uncheck-all`, stranger, {}); + + expect(response.status).toBe(404); + expect(await rows(server.sql`select id from items where list_id = ${listId} and checked`)).toHaveLength(1); + }); +}); diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..473f037 --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import type { AppError, RequestIssue } from "../lib/errors"; +import { err, fromPromise, ok, type Result } from "../lib/result"; + +/** + * The one place an error `kind` becomes a status code (CONVENTIONS.md, "Errors are values"). The + * `never` assignment is the point of it: a new variant fails to compile here rather than + * silently becoming a 500 somewhere. + */ +export function statusFor(error: AppError): number { + switch (error.kind) { + case "unauthenticated": + return 401; + case "forbidden": + return 403; + case "not_found": + return 404; + case "invalid_request": + return 400; + default: { + const unhandled: never = error; + throw new Error(`unhandled error kind: ${JSON.stringify(unhandled)}`); + } + } +} + +/** A domain `Result` as a response. A `null` value means the action left nothing to return. */ +export function respond(result: Result, status = 200): Response { + if (!result.ok) { + return Response.json({ error: result.error.kind, ...detailsOf(result.error) }, { status: statusFor(result.error) }); + } + if (result.value === null) return new Response(null, { status: 204 }); + return Response.json(result.value, { status }); +} + +/** An id in a path is untrusted input like any other, so it is parsed, not cast. */ +const idSchema = z.uuid(); + +export function parseId(value: string | undefined, name: string): Result { + const parsed = idSchema.safeParse(value); + if (!parsed.success) return err({ kind: "invalid_request", issues: [{ path: name, message: "not an id" }] }); + return ok(parsed.data); +} + +export async function parseBody(req: Request, schema: z.ZodType): Promise> { + // A body that is not JSON at all is a client bug, not a crash: `req.json()` throws, so it is + // wrapped at its boundary. + const body = await fromPromise(req.json() as Promise, () => null); + if (!body.ok) return err({ kind: "invalid_request", issues: [{ path: "", message: "expected a JSON body" }] }); + + const parsed = schema.safeParse(body.value); + if (!parsed.success) return err({ kind: "invalid_request", issues: issuesOf(parsed.error) }); + return ok(parsed.data); +} + +function issuesOf(error: z.ZodError): RequestIssue[] { + return error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })); +} + +function detailsOf(error: AppError): Record { + return error.kind === "invalid_request" ? { issues: error.issues } : {}; +} diff --git a/src/api/routes.ts b/src/api/routes.ts new file mode 100644 index 0000000..a20d672 --- /dev/null +++ b/src/api/routes.ts @@ -0,0 +1,167 @@ +import type { BunRequest, SQL } from "bun"; +import type { Auth } from "../auth/auth"; +import { addItem, clearCheckedItems, editItem, removeItem, setItemChecked, uncheckAllItems } from "../items/items"; +import { createList, deleteList, listListsFor, readList, renameList } from "../lists/lists"; +import { + clearCheckedItemsSchema, + createItemSchema, + createListSchema, + setItemCheckedSchema, + uncheckAllItemsSchema, + updateItemSchema, + updateListSchema, +} from "../lib/schemas"; +import { map, ok, type Result } from "../lib/result"; +import type { AppError } from "../lib/errors"; +import { parseBody, parseId, respond } from "./http"; +import { requireAccount } from "./session"; + +/** + * Route handlers parse input, call one domain function and map its `Result` to a response, and + * nothing else (CONVENTIONS.md, "Where logic lives"). No handler reads a Role or decides who may + * do what: that lives behind `can()` in the domain layer (ADR-0005). + */ +export type ApiDeps = { sql: SQL; auth: Auth }; + +export function apiRoutes({ sql, auth }: ApiDeps) { + return { + "/api/lists": { + GET: async (req: BunRequest<"/api/lists">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + + const lists = await listListsFor(sql, actor.value); + return respond(map(lists, (value) => ({ lists: value }))); + }, + + POST: async (req: BunRequest<"/api/lists">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const input = await parseBody(req, createListSchema); + if (!input.ok) return respond(input); + + return respond(await createList(sql, actor.value, input.value), 201); + }, + }, + + "/api/lists/:listId": { + GET: async (req: BunRequest<"/api/lists/:listId">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const listId = parseId(req.params.listId, "listId"); + if (!listId.ok) return respond(listId); + + return respond(await readList(sql, actor.value, listId.value)); + }, + + PATCH: async (req: BunRequest<"/api/lists/:listId">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const listId = parseId(req.params.listId, "listId"); + if (!listId.ok) return respond(listId); + const input = await parseBody(req, updateListSchema); + if (!input.ok) return respond(input); + + return respond(await renameList(sql, actor.value, listId.value, input.value)); + }, + + DELETE: async (req: BunRequest<"/api/lists/:listId">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const listId = parseId(req.params.listId, "listId"); + if (!listId.ok) return respond(listId); + + return respond(await deleteList(sql, actor.value, listId.value)); + }, + }, + + "/api/lists/:listId/items": { + POST: async (req: BunRequest<"/api/lists/:listId/items">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const listId = parseId(req.params.listId, "listId"); + if (!listId.ok) return respond(listId); + const input = await parseBody(req, createItemSchema); + if (!input.ok) return respond(input); + + return respond(await addItem(sql, actor.value, listId.value, input.value), 201); + }, + }, + + "/api/lists/:listId/items/:itemId": { + PATCH: async (req: BunRequest<"/api/lists/:listId/items/:itemId">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const ids = parseItemPath(req.params); + if (!ids.ok) return respond(ids); + const input = await parseBody(req, updateItemSchema); + if (!input.ok) return respond(input); + + return respond(await editItem(sql, actor.value, ids.value.listId, ids.value.itemId, input.value)); + }, + + DELETE: async (req: BunRequest<"/api/lists/:listId/items/:itemId">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const ids = parseItemPath(req.params); + if (!ids.ok) return respond(ids); + + return respond(await removeItem(sql, actor.value, ids.value.listId, ids.value.itemId)); + }, + }, + + // Checking an Item is its own endpoint, so an edit behind save/cancel and an instant tick + // never contend for the same payload. + "/api/lists/:listId/items/:itemId/checked": { + PUT: async (req: BunRequest<"/api/lists/:listId/items/:itemId/checked">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const ids = parseItemPath(req.params); + if (!ids.ok) return respond(ids); + const input = await parseBody(req, setItemCheckedSchema); + if (!input.ok) return respond(input); + + return respond(await setItemChecked(sql, actor.value, ids.value.listId, ids.value.itemId, input.value)); + }, + }, + + // The bulk actions hang off the List, not off `items`, because the action is the whole List + // and takes no Items to act on. + "/api/lists/:listId/clear-checked": { + POST: async (req: BunRequest<"/api/lists/:listId/clear-checked">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const listId = parseId(req.params.listId, "listId"); + if (!listId.ok) return respond(listId); + const input = await parseBody(req, clearCheckedItemsSchema); + if (!input.ok) return respond(input); + + return respond(await clearCheckedItems(sql, actor.value, listId.value)); + }, + }, + + "/api/lists/:listId/uncheck-all": { + POST: async (req: BunRequest<"/api/lists/:listId/uncheck-all">) => { + const actor = await requireAccount(req, auth); + if (!actor.ok) return respond(actor); + const listId = parseId(req.params.listId, "listId"); + if (!listId.ok) return respond(listId); + const input = await parseBody(req, uncheckAllItemsSchema); + if (!input.ok) return respond(input); + + return respond(await uncheckAllItems(sql, actor.value, listId.value)); + }, + }, + }; +} + +type ItemPath = { listId: string; itemId: string }; + +function parseItemPath(params: ItemPath): Result { + const listId = parseId(params.listId, "listId"); + if (!listId.ok) return listId; + const itemId = parseId(params.itemId, "itemId"); + if (!itemId.ok) return itemId; + + return ok({ listId: listId.value, itemId: itemId.value }); +} diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..144025d --- /dev/null +++ b/src/api/session.ts @@ -0,0 +1,15 @@ +import type { Auth } from "../auth/auth"; +import type { AppError } from "../lib/errors"; +import type { Account } from "../lib/permissions"; +import { err, ok, type Result } from "../lib/result"; + +/** + * The actor behind a request. Every visitor has an Account from their first visit (ADR-0003), so + * a missing session means a client that dropped its cookie, not a guest mode. + */ +export async function requireAccount(req: Request, auth: Auth): Promise> { + const session = await auth.api.getSession({ headers: req.headers }); + if (session === null) return err({ kind: "unauthenticated" }); + + return ok({ id: session.user.id }); +} diff --git a/src/api/test-server.ts b/src/api/test-server.ts new file mode 100644 index 0000000..10bedf0 --- /dev/null +++ b/src/api/test-server.ts @@ -0,0 +1,59 @@ +import { serve } from "bun"; +import { join } from "node:path"; +import { createAuth } from "../auth/auth"; +import { runMigrations } from "../db/migrate"; +import { createTestDatabase, type TestDatabase } from "../db/test-database"; +import { apiRoutes } from "./routes"; + +/** + * A real server on a real Postgres with the real migrations and real Better Auth sessions + * (CONVENTIONS.md, "Tests"). Nothing here is a stand-in: an integration test that signs in gets + * an Anonymous Account the same way a browser does. + */ +export type TestServer = { + url: string; + sql: TestDatabase["sql"]; + /** A fresh Anonymous Account, as its cookie header and its id. */ + signIn: () => Promise; + stop: () => Promise; +}; + +export type TestActor = { cookie: string; accountId: string }; + +export async function createTestServer(): Promise { + const db = await createTestDatabase(); + const migrated = await runMigrations(db.sql, join(import.meta.dir, "../../migrations")); + if (!migrated.ok) throw new Error(`test migrations failed: ${JSON.stringify(migrated.error)}`); + + // Plain HTTP, so the session cookie is not Secure and travels to a loopback port. + const auth = createAuth({ + sql: db.sql, + secret: "test-secret-not-a-real-one", + publicUrl: new URL("http://localhost"), + }); + const server = serve({ port: 0, routes: apiRoutes({ sql: db.sql, auth }) }); + + return { + url: server.url.toString().replace(/\/$/, ""), + sql: db.sql, + signIn: () => signInAnonymously(auth), + stop: async () => { + await server.stop(true); + await db.drop(); + }, + }; +} + +async function signInAnonymously(auth: ReturnType): Promise { + // `returnHeaders` hands back the Set-Cookie Better Auth would have written; its types do not + // model that overload, hence the cast. + const { headers } = (await auth.api.signInAnonymous({ returnHeaders: true } as never)) as { headers: Headers }; + const cookie = headers + .getSetCookie() + .map((value) => value.split(";")[0]) + .join("; "); + + const session = await auth.api.getSession({ headers: new Headers({ cookie }) }); + if (session === null) throw new Error("anonymous sign-in produced no session"); + return { cookie, accountId: session.user.id }; +} diff --git a/src/items/items.ts b/src/items/items.ts new file mode 100644 index 0000000..04b5cfb --- /dev/null +++ b/src/items/items.ts @@ -0,0 +1,62 @@ +import type { SQL } from "bun"; +import type { AppError } from "../lib/errors"; +import type { Account } from "../lib/permissions"; +import { err, type Result } from "../lib/result"; +import type { ItemView } from "../lists/lists"; +import type { CreateItemInput, SetItemCheckedInput, UpdateItemInput } from "../lib/schemas"; + +const notImplemented = (): Result => err({ kind: "not_found" }); + +export async function addItem( + _sql: SQL, + _actor: Account, + _listId: string, + _input: CreateItemInput, +): Promise> { + return notImplemented(); +} + +export async function editItem( + _sql: SQL, + _actor: Account, + _listId: string, + _itemId: string, + _input: UpdateItemInput, +): Promise> { + return notImplemented(); +} + +export async function removeItem( + _sql: SQL, + _actor: Account, + _listId: string, + _itemId: string, +): Promise> { + return notImplemented(); +} + +export async function setItemChecked( + _sql: SQL, + _actor: Account, + _listId: string, + _itemId: string, + _input: SetItemCheckedInput, +): Promise> { + return notImplemented(); +} + +export async function clearCheckedItems( + _sql: SQL, + _actor: Account, + _listId: string, +): Promise> { + return notImplemented(); +} + +export async function uncheckAllItems( + _sql: SQL, + _actor: Account, + _listId: string, +): Promise> { + return notImplemented(); +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..f86d904 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,19 @@ +import type { Permission } from "./permissions"; + +/** + * The expected failures of a domain function, carried as values (CONVENTIONS.md, "Errors are + * values"). They are mapped to HTTP status codes in exactly one place, `statusFor` in + * `src/api/http.ts`, so adding a variant here makes TypeScript name the spot that has not + * handled it. + * + * `not_found` covers a List an Account has no Membership on: it cannot see the List at all + * (CONTEXT.md, "Membership"), so it is never told that one exists. `forbidden` is for a Member + * who can see the List but lacks the Permission for this action. + */ +export type AppError = + | { kind: "unauthenticated" } + | { kind: "not_found" } + | { kind: "forbidden"; permission: Permission } + | { kind: "invalid_request"; issues: RequestIssue[] }; + +export type RequestIssue = { path: string; message: string }; diff --git a/src/lists/lists.ts b/src/lists/lists.ts new file mode 100644 index 0000000..543c5c0 --- /dev/null +++ b/src/lists/lists.ts @@ -0,0 +1,50 @@ +import type { SQL } from "bun"; +import type { AppError } from "../lib/errors"; +import type { Account } from "../lib/permissions"; +import { err, type Result } from "../lib/result"; +import type { CreateListInput, UpdateListInput } from "../lib/schemas"; + +export type ListSummary = { id: string; name: string; createdAt: string }; + +export type ItemView = { + id: string; + listId: string; + name: string; + quantity: number | null; + unit: string | null; + note: string | null; + checked: boolean; +}; + +export type ListWithItems = { list: ListSummary; items: ItemView[] }; + +const notImplemented = (): Result => err({ kind: "not_found" }); + +export async function createList( + _sql: SQL, + _actor: Account, + _input: CreateListInput, +): Promise> { + return notImplemented(); +} + +export async function listListsFor(_sql: SQL, _actor: Account): Promise> { + return notImplemented(); +} + +export async function readList(_sql: SQL, _actor: Account, _listId: string): Promise> { + return notImplemented(); +} + +export async function renameList( + _sql: SQL, + _actor: Account, + _listId: string, + _input: UpdateListInput, +): Promise> { + return notImplemented(); +} + +export async function deleteList(_sql: SQL, _actor: Account, _listId: string): Promise> { + return notImplemented(); +} From 7fe933d128506b3f64285782b3e49bab28b46491 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:59:11 +0300 Subject: [PATCH 18/36] test: specify the migration runner's failure boundaries, red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps, each red for a behavioural reason: an unreadable .sql file throws EISDIR straight out of a Promise>; a lock release that fails replaces the returned migration_failed with a driver error; and an applied migration whose file is gone is accepted silently. Co-Authored-By: Claude Opus 5 (1M context) --- src/db/migrate.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index 36dfcfb..7043faa 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { SQL } from "bun"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createTestDatabase, type TestDatabase } from "./test-database"; @@ -91,3 +92,66 @@ test("two instances booting at once apply each migration exactly once", async () expect(appliedByBoth).toEqual(["0001_first.sql", "0002_second.sql"]); expect(await appliedNames(db)).toEqual(["0001_first.sql", "0002_second.sql"]); }); + +test("a .sql entry that cannot be read comes back as a value, not a throw", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + // A directory named like a migration is the reproducible stand-in for a file that is + // unreadable, or that vanishes between the listing and the read. + await mkdir(join(dir, "0002_unreadable.sql")); + + const result = await runMigrations(db.sql, dir); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error.kind).toBe("migrations_unreadable"); +}); + +test("a failing unlock does not hide which migration failed", async () => { + // The migration kills every other backend in this database — including the connection holding + // the advisory lock — and then fails, so releasing the lock throws on the way out. + await writeMigration( + "0001_suicidal.sql", + `select pg_terminate_backend(pid) from pg_stat_activity + where datname = current_database() and pid <> pg_backend_pid(); + select 1 / 0;`, + ); + + const result = await runMigrations(db.sql, dir); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error.kind).toBe("migration_failed"); + expect((result.error as { name: string }).name).toBe("0001_suicidal.sql"); +}); + +test("a failed run releases its connection, so the next run still has a pool to work with", async () => { + // Two connections is exactly what one run needs: one reserved for the lock, one for the + // transaction. A run that strands its reserved connection leaves the next one waiting forever. + const pool = new SQL(db.url, { max: 2 }); + await writeMigration( + "0001_suicidal.sql", + `select pg_terminate_backend(pid) from pg_stat_activity + where datname = current_database() and pid <> pg_backend_pid(); + select 1 / 0;`, + ); + await runMigrations(pool, dir).catch(() => undefined); + + await rm(join(dir, "0001_suicidal.sql")); + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + const second = await runMigrations(pool, dir); + await pool.end(); + + expect(second).toEqual({ ok: true, value: ["0001_first.sql"] }); +}); + +test("an applied migration whose file is gone stops the boot", async () => { + await writeMigration("0001_first.sql", "create table widgets (id text primary key);"); + await runMigrations(db.sql, dir); + await rm(join(dir, "0001_first.sql")); + + const result = await runMigrations(db.sql, dir); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toEqual({ kind: "migration_missing", name: "0001_first.sql" }); +}); From 95d343cfe4f32fc2ea866002be76e779843537fe Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:59:24 +0300 Subject: [PATCH 19/36] feat: implement the Lists and Items endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers: handlers parse and map, domain functions decide, query functions hold the SQL. Every action passes `authoriseList`, the one caller of `can()`, which reports a List the Account has no Membership on as missing rather than forbidden — a 403 would confirm it exists. Clearing an Item's quantity clears its unit with it: a unit measures nothing on its own, which is what the schemas and the check constraint already say. Co-Authored-By: Claude Opus 5 (1M context) --- src/items/items.ts | 116 +++++++++++++++++++++++++++------------- src/items/queries.ts | 123 +++++++++++++++++++++++++++++++++++++++++++ src/lists/access.ts | 29 ++++++++++ src/lists/lists.ts | 71 +++++++++++++------------ src/lists/queries.ts | 82 +++++++++++++++++++++++++++++ 5 files changed, 351 insertions(+), 70 deletions(-) create mode 100644 src/items/queries.ts create mode 100644 src/lists/access.ts create mode 100644 src/lists/queries.ts diff --git a/src/items/items.ts b/src/items/items.ts index 04b5cfb..d69fe6a 100644 --- a/src/items/items.ts +++ b/src/items/items.ts @@ -1,62 +1,104 @@ import type { SQL } from "bun"; import type { AppError } from "../lib/errors"; import type { Account } from "../lib/permissions"; -import { err, type Result } from "../lib/result"; -import type { ItemView } from "../lists/lists"; +import { err, ok, type Result } from "../lib/result"; import type { CreateItemInput, SetItemCheckedInput, UpdateItemInput } from "../lib/schemas"; +import { authoriseList } from "../lists/access"; +import { + deleteCheckedItems, + deleteItemById, + insertItem, + uncheckItems, + updateItemChecked, + updateItemFields, + type ItemRecord, +} from "./queries"; -const notImplemented = (): Result => err({ kind: "not_found" }); - +/** The decisions about Items. The Permission for each action is named at its `authoriseList`. */ export async function addItem( - _sql: SQL, - _actor: Account, - _listId: string, - _input: CreateItemInput, -): Promise> { - return notImplemented(); + sql: SQL, + actor: Account, + listId: string, + input: CreateItemInput, +): Promise> { + const list = await authoriseList(sql, actor, listId, "item:create"); + if (!list.ok) return list; + + return ok(await insertItem(sql, listId, input)); } export async function editItem( - _sql: SQL, - _actor: Account, - _listId: string, - _itemId: string, - _input: UpdateItemInput, -): Promise> { - return notImplemented(); + sql: SQL, + actor: Account, + listId: string, + itemId: string, + input: UpdateItemInput, +): Promise> { + const list = await authoriseList(sql, actor, listId, "item:update"); + if (!list.ok) return list; + + const edited = await updateItemFields(sql, listId, itemId, withoutAnOrphanedUnit(input)); + return edited === undefined ? err({ kind: "not_found" }) : ok(edited); } export async function removeItem( - _sql: SQL, - _actor: Account, - _listId: string, - _itemId: string, + sql: SQL, + actor: Account, + listId: string, + itemId: string, ): Promise> { - return notImplemented(); + const list = await authoriseList(sql, actor, listId, "item:delete"); + if (!list.ok) return list; + + const removed = await deleteItemById(sql, listId, itemId); + return removed ? ok(null) : err({ kind: "not_found" }); } +/** + * Checking is its own action, and a Checked Item stays on its List: nothing here removes it + * (CONTEXT.md, "Checked"). + */ export async function setItemChecked( - _sql: SQL, - _actor: Account, - _listId: string, - _itemId: string, - _input: SetItemCheckedInput, -): Promise> { - return notImplemented(); + sql: SQL, + actor: Account, + listId: string, + itemId: string, + input: SetItemCheckedInput, +): Promise> { + const list = await authoriseList(sql, actor, listId, "item:check"); + if (!list.ok) return list; + + const checked = await updateItemChecked(sql, listId, itemId, input.checked); + return checked === undefined ? err({ kind: "not_found" }) : ok(checked); } export async function clearCheckedItems( - _sql: SQL, - _actor: Account, - _listId: string, + sql: SQL, + actor: Account, + listId: string, ): Promise> { - return notImplemented(); + const list = await authoriseList(sql, actor, listId, "item:clear_checked"); + if (!list.ok) return list; + + return ok({ removed: await deleteCheckedItems(sql, listId) }); } export async function uncheckAllItems( - _sql: SQL, - _actor: Account, - _listId: string, + sql: SQL, + actor: Account, + listId: string, ): Promise> { - return notImplemented(); + const list = await authoriseList(sql, actor, listId, "item:uncheck_all"); + if (!list.ok) return list; + + return ok({ unchecked: await uncheckItems(sql, listId) }); +} + +/** + * Clearing the quantity clears the unit with it. The payload may legally say only + * `{ quantity: null }`, but the Item would be left measuring "kg" of nothing — a state the Zod + * schemas and the database both refuse for every other route into it. + */ +function withoutAnOrphanedUnit(input: UpdateItemInput): UpdateItemInput { + return input.quantity === null ? { ...input, unit: null } : input; } diff --git a/src/items/queries.ts b/src/items/queries.ts new file mode 100644 index 0000000..a865d0d --- /dev/null +++ b/src/items/queries.ts @@ -0,0 +1,123 @@ +import type { SQL } from "bun"; +import type { CreateItemInput, UpdateItemInput } from "../lib/schemas"; + +/** Every line of SQL about Items, and no decisions (CONVENTIONS.md, "Where logic lives"). */ +export type ItemRecord = { + id: string; + listId: string; + name: string; + quantity: number | null; + unit: string | null; + note: string | null; + checked: boolean; +}; + +type ItemRow = { + id: string; + list_id: string; + name: string; + quantity: number | null; + unit: string | null; + note: string | null; + checked: boolean; +}; + +/** + * No `order by`: the server never orders Items, because Items have no inherent order. Sorting is + * the client's, and only the client's, business. + */ +export async function selectItemsForList(sql: SQL, listId: string): Promise { + const rows = (await sql` + select id, list_id, name, quantity, unit, note, checked from items where list_id = ${listId} + `) as ItemRow[]; + + return rows.map(toItemRecord); +} + +export async function insertItem(sql: SQL, listId: string, input: CreateItemInput): Promise { + const [row] = (await sql` + insert into items (list_id, name, quantity, unit, note) + values (${listId}, ${input.name}, ${input.quantity ?? null}, ${input.unit ?? null}, ${input.note ?? null}) + returning id, list_id, name, quantity, unit, note, checked + `) as ItemRow[]; + if (row === undefined) throw new Error("insert into items returned no row"); + + return toItemRecord(row); +} + +/** + * A partial update in one statement: a field the payload does not mention keeps its column, and + * `null` clears it. The `case when` flags say which fields the payload mentioned at all, which + * `coalesce` alone cannot express — `null` means "clear this", not "leave it". + */ +export async function updateItemFields( + sql: SQL, + listId: string, + itemId: string, + patch: UpdateItemInput, +): Promise { + const [row] = (await sql` + update items set + name = coalesce(${patch.name ?? null}::text, name), + quantity = case when ${"quantity" in patch}::boolean then ${patch.quantity ?? null}::double precision else quantity end, + unit = case when ${"unit" in patch}::boolean then ${patch.unit ?? null}::text else unit end, + note = case when ${"note" in patch}::boolean then ${patch.note ?? null}::text else note end, + updated_at = now() + where id = ${itemId} and list_id = ${listId} + returning id, list_id, name, quantity, unit, note, checked + `) as ItemRow[]; + + return row === undefined ? undefined : toItemRecord(row); +} + +export async function updateItemChecked( + sql: SQL, + listId: string, + itemId: string, + checked: boolean, +): Promise { + const [row] = (await sql` + update items set checked = ${checked}, updated_at = now() + where id = ${itemId} and list_id = ${listId} + returning id, list_id, name, quantity, unit, note, checked + `) as ItemRow[]; + + return row === undefined ? undefined : toItemRecord(row); +} + +export async function deleteItemById(sql: SQL, listId: string, itemId: string): Promise { + const rows = (await sql`delete from items where id = ${itemId} and list_id = ${listId} returning id`) as { + id: string; + }[]; + + return rows.length > 0; +} + +/** A Checked Item is removed only by this action, never as a side effect of being Checked. */ +export async function deleteCheckedItems(sql: SQL, listId: string): Promise { + const rows = (await sql`delete from items where list_id = ${listId} and checked returning id`) as { id: string }[]; + + return rows.length; +} + +export async function uncheckItems(sql: SQL, listId: string): Promise { + const rows = (await sql` + update items set checked = false, updated_at = now() + where list_id = ${listId} and checked + returning id + `) as { id: string }[]; + + return rows.length; +} + +function toItemRecord(row: ItemRow): ItemRecord { + return { + id: row.id, + listId: row.list_id, + name: row.name, + quantity: row.quantity, + unit: row.unit, + note: row.note, + checked: row.checked, + }; +} diff --git a/src/lists/access.ts b/src/lists/access.ts new file mode 100644 index 0000000..3135b9d --- /dev/null +++ b/src/lists/access.ts @@ -0,0 +1,29 @@ +import type { SQL } from "bun"; +import type { AppError } from "../lib/errors"; +import { can, type Account, type Permission } from "../lib/permissions"; +import { err, ok, type Result } from "../lib/result"; +import { selectListWithMemberships, type ListRecord } from "./queries"; + +/** + * The one gate every List and Item action passes through. `can()` is the only capability check in + * the app (ADR-0005), so it is called here rather than in each domain function, and the + * Memberships are loaded alongside the List because they are the only data that answers it. + * + * A List an Account has no Membership on is reported as missing, not as forbidden: it cannot see + * the List at all (CONTEXT.md, "Membership"), and a 403 would confirm that it exists. + */ +export async function authoriseList( + sql: SQL, + actor: Account, + listId: string, + permission: Permission, +): Promise> { + const found = await selectListWithMemberships(sql, listId); + if (found === undefined) return err({ kind: "not_found" }); + + const list = { id: found.list.id, memberships: found.memberships }; + if (!can(actor, "list:read", list)) return err({ kind: "not_found" }); + if (!can(actor, permission, list)) return err({ kind: "forbidden", permission }); + + return ok(found.list); +} diff --git a/src/lists/lists.ts b/src/lists/lists.ts index 543c5c0..561a421 100644 --- a/src/lists/lists.ts +++ b/src/lists/lists.ts @@ -1,50 +1,55 @@ import type { SQL } from "bun"; +import { selectItemsForList, type ItemRecord } from "../items/queries"; import type { AppError } from "../lib/errors"; import type { Account } from "../lib/permissions"; -import { err, type Result } from "../lib/result"; +import { err, ok, type Result } from "../lib/result"; import type { CreateListInput, UpdateListInput } from "../lib/schemas"; +import { authoriseList } from "./access"; +import { deleteListById, insertListWithOwner, selectListsForAccount, updateListName, type ListRecord } from "./queries"; -export type ListSummary = { id: string; name: string; createdAt: string }; - -export type ItemView = { - id: string; - listId: string; - name: string; - quantity: number | null; - unit: string | null; - note: string | null; - checked: boolean; -}; - -export type ListWithItems = { list: ListSummary; items: ItemView[] }; - -const notImplemented = (): Result => err({ kind: "not_found" }); +/** The decisions about Lists. Every one of them starts by asking `can()` (ADR-0005). */ +export type ListWithItems = { list: ListRecord; items: ItemRecord[] }; +/** Creating a List makes its creator an Owner; nobody has to be granted access to their own List. */ export async function createList( - _sql: SQL, - _actor: Account, - _input: CreateListInput, -): Promise> { - return notImplemented(); + sql: SQL, + actor: Account, + input: CreateListInput, +): Promise> { + return ok(await insertListWithOwner(sql, input.name, actor.id)); } -export async function listListsFor(_sql: SQL, _actor: Account): Promise> { - return notImplemented(); +export async function listListsFor(sql: SQL, actor: Account): Promise> { + return ok(await selectListsForAccount(sql, actor.id)); } -export async function readList(_sql: SQL, _actor: Account, _listId: string): Promise> { - return notImplemented(); +export async function readList(sql: SQL, actor: Account, listId: string): Promise> { + const list = await authoriseList(sql, actor, listId, "list:read"); + if (!list.ok) return list; + + return ok({ list: list.value, items: await selectItemsForList(sql, listId) }); } +/** Renaming is not an Owner-only action: an Editor edits the List as well as its Items. */ export async function renameList( - _sql: SQL, - _actor: Account, - _listId: string, - _input: UpdateListInput, -): Promise> { - return notImplemented(); + sql: SQL, + actor: Account, + listId: string, + input: UpdateListInput, +): Promise> { + const list = await authoriseList(sql, actor, listId, "list:update"); + if (!list.ok) return list; + + const renamed = await updateListName(sql, listId, input.name); + // The row was there a statement ago; if it is gone now, a concurrent delete won the race and + // the List is, correctly, not found. + return renamed === undefined ? err({ kind: "not_found" }) : ok(renamed); } -export async function deleteList(_sql: SQL, _actor: Account, _listId: string): Promise> { - return notImplemented(); +export async function deleteList(sql: SQL, actor: Account, listId: string): Promise> { + const list = await authoriseList(sql, actor, listId, "list:delete"); + if (!list.ok) return list; + + await deleteListById(sql, listId); + return ok(null); } diff --git a/src/lists/queries.ts b/src/lists/queries.ts new file mode 100644 index 0000000..e7e5bbf --- /dev/null +++ b/src/lists/queries.ts @@ -0,0 +1,82 @@ +import type { SQL } from "bun"; +import type { Membership, Role } from "../lib/permissions"; + +/** + * Every line of SQL about Lists and Memberships, and no decisions (CONVENTIONS.md, "Where logic + * lives"). Rows come back in the domain's shape so that snake_case stops at this file. + */ +export type ListRecord = { id: string; name: string; createdAt: string }; + +export type ListWithMemberships = { list: ListRecord; memberships: Membership[] }; + +type ListRow = { id: string; name: string; created_at: Date }; +type MembershipRow = { list_id: string; account_id: string; role: Role }; + +/** + * The List and its creator's Owner Membership commit together: a List that exists with nobody + * able to see it would be unreachable forever, since only `can()` grants access and it reads + * Memberships. + */ +export async function insertListWithOwner(sql: SQL, name: string, ownerAccountId: string): Promise { + const row = (await sql.begin(async (tx) => { + const [created] = (await tx` + insert into lists (name) values (${name}) + returning id, name, created_at + `) as ListRow[]; + if (created === undefined) throw new Error("insert into lists returned no row"); + + await tx` + insert into memberships (list_id, account_id, role) + values (${created.id}, ${ownerAccountId}, 'owner') + `; + return created; + })) as unknown as ListRow; + + return toListRecord(row); +} + +export async function selectListWithMemberships(sql: SQL, listId: string): Promise { + const [list] = (await sql`select id, name, created_at from lists where id = ${listId}`) as ListRow[]; + if (list === undefined) return undefined; + + const memberships = (await sql` + select list_id, account_id, role from memberships where list_id = ${listId} + `) as MembershipRow[]; + + return { list: toListRecord(list), memberships: memberships.map(toMembership) }; +} + +export async function selectListsForAccount(sql: SQL, accountId: string): Promise { + const rows = (await sql` + select lists.id, lists.name, lists.created_at + from lists + join memberships on memberships.list_id = lists.id + where memberships.account_id = ${accountId} + order by lower(lists.name) + `) as ListRow[]; + + return rows.map(toListRecord); +} + +export async function updateListName(sql: SQL, listId: string, name: string): Promise { + const [row] = (await sql` + update lists set name = ${name}, updated_at = now() + where id = ${listId} + returning id, name, created_at + `) as ListRow[]; + + return row === undefined ? undefined : toListRecord(row); +} + +/** Items and Memberships go with the List through `on delete cascade`. */ +export async function deleteListById(sql: SQL, listId: string): Promise { + await sql`delete from lists where id = ${listId}`; +} + +function toListRecord(row: ListRow): ListRecord { + return { id: row.id, name: row.name, createdAt: row.created_at.toISOString() }; +} + +function toMembership(row: MembershipRow): Membership { + return { listId: row.list_id, accountId: row.account_id, role: row.role }; +} From 826471112721242c9817f33eb6abc23b180ed344 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:59:32 +0300 Subject: [PATCH 20/36] fix: keep an unreadable migration file inside the Result boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a .sql file sat outside the try that guarded the directory listing, so a file that is unreadable or that vanishes between the two threw past a Promise> and killed the boot with a raw stack instead of the refusal a self-hoster can act on (CONVENTIONS.md, "Errors are values"). The error now also names the file it could not read. Co-Authored-By: Claude Opus 5 (1M context) --- src/db/migrate.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 02c31b9..9892ec0 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -9,7 +9,7 @@ import { err, ok, type Result } from "../lib/result"; * boot fails can read the file that failed. */ export type MigrationError = - | { kind: "migrations_unreadable"; dir: string; cause: unknown } + | { kind: "migrations_unreadable"; dir: string; name?: string; cause: unknown } | { kind: "migration_failed"; name: string; cause: unknown } | { kind: "migration_changed"; name: string }; @@ -70,8 +70,14 @@ async function readMigrationFiles(dir: string): Promise Date: Tue, 15 Sep 2026 23:59:46 +0300 Subject: [PATCH 21/36] fix: stop a failed lock release masking the migration error A throw inside the finally replaced the returned migration_failed, so the operator saw a driver error instead of the file that failed, and the reserved connection was never returned to the pool. The unlock is now best effort and the release is unconditional. Co-Authored-By: Claude Opus 5 (1M context) --- src/db/migrate.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 9892ec0..bc38a8e 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -1,4 +1,4 @@ -import type { SQL } from "bun"; +import type { ReservedSQL, SQL } from "bun"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; import { err, ok, type Result } from "../lib/result"; @@ -55,7 +55,24 @@ export async function runMigrations(sql: SQL, dir: string): Promise { + try { await lock`select pg_advisory_unlock(${MIGRATION_LOCK_KEY})`; + } catch (cause) { + console.warn("Could not release the migration advisory lock; the connection ended with it:", cause); + } finally { lock.release(); } } From 73d764bc80420ef6141cc411bb6b7251bcee61e7 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Tue, 15 Sep 2026 23:59:58 +0300 Subject: [PATCH 22/36] fix: detect an applied migration whose file is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift check iterated the files on disk, so a _migrations row with no file — a deleted or renamed migration, or an older image against a newer database — passed silently while the comment claimed the check caught any disagreement between the repo and the database. It now walks the applied rows, and the comment describes what it does. Co-Authored-By: Claude Opus 5 (1M context) --- src/db/migrate.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/db/migrate.ts b/src/db/migrate.ts index bc38a8e..b4e2b76 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -11,7 +11,8 @@ import { err, ok, type Result } from "../lib/result"; export type MigrationError = | { kind: "migrations_unreadable"; dir: string; name?: string; cause: unknown } | { kind: "migration_failed"; name: string; cause: unknown } - | { kind: "migration_changed"; name: string }; + | { kind: "migration_changed"; name: string } + | { kind: "migration_missing"; name: string }; /** * Two app instances booting at once would otherwise both see the same migration as pending and @@ -44,8 +45,8 @@ export async function runMigrations(sql: SQL, dir: string): Promise !applied.has(file.name)); - const unchanged = assertAppliedFilesUnchanged(files.value, applied); - if (!unchanged.ok) return unchanged; + const agreed = assertRepoAndDatabaseAgree(files.value, applied); + if (!agreed.ok) return agreed; const appliedNow: string[] = []; for (const file of pending) { @@ -105,19 +106,22 @@ async function readAppliedMigrations(sql: SQL): Promise> { } /** - * An applied file that has changed on disk means the database and the repo disagree about what - * the schema is. Refusing to boot is the only honest answer: re-running it is not safe, and - * pretending it matches hides the drift until a much later query fails. + * The database and the repo must describe the same schema, in both directions: an applied file + * that changed on disk, and an applied migration whose file is no longer there — a deleted or + * renamed file, or an older image pointed at a newer database. Refusing to boot is the only + * honest answer: re-running a changed file is not safe, and the schema behind a missing one + * cannot be reasoned about at all. Either way the drift surfaces here, at boot, with a name in + * it, rather than as a puzzling query failure much later. */ -function assertAppliedFilesUnchanged( +function assertRepoAndDatabaseAgree( files: MigrationFile[], applied: Map, ): Result { - for (const file of files) { - const checksum = applied.get(file.name); - if (checksum !== undefined && checksum !== file.checksum) { - return err({ kind: "migration_changed", name: file.name }); - } + const onDisk = new Map(files.map((file) => [file.name, file])); + for (const [name, checksum] of applied) { + const file = onDisk.get(name); + if (file === undefined) return err({ kind: "migration_missing", name }); + if (file.checksum !== checksum) return err({ kind: "migration_changed", name }); } return ok(null); } From 425f429409c2c15d28c038251208247654611451 Mon Sep 17 00:00:00 2001 From: Juuso Elo-Rauta Date: Wed, 16 Sep 2026 00:02:16 +0300 Subject: [PATCH 23/36] feat: add the React UI and mount the API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instant actions — tick, delete, clear checked, uncheck all — fire on the click and reconcile with the server's answer. Edited fields (name, quantity, unit, note, and the List's name) sit behind Save and Cancel: the draft lives in the editing component, so Cancel sends nothing at all and nothing is ever debounced (ADR-0002). Sorting is client-side, un-Checked first and alphabetical within each group; `byCheckedThenName` joins the pure core with its own unit tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/APITester.tsx | 39 ------ src/App.tsx | 24 ---- src/frontend.tsx | 2 +- src/index.css | 268 ++++++++++++++++---------------------- src/index.html | 2 +- src/index.ts | 12 +- src/lib/sort.test.ts | 31 +++++ src/lib/sort.ts | 12 ++ src/ui/App.tsx | 299 +++++++++++++++++++++++++++++++++++++++++++ src/ui/ItemRow.tsx | 117 +++++++++++++++++ src/ui/api.ts | 85 ++++++++++++ 11 files changed, 660 insertions(+), 231 deletions(-) delete mode 100644 src/APITester.tsx delete mode 100644 src/App.tsx create mode 100644 src/lib/sort.test.ts create mode 100644 src/lib/sort.ts create mode 100644 src/ui/App.tsx create mode 100644 src/ui/ItemRow.tsx create mode 100644 src/ui/api.ts diff --git a/src/APITester.tsx b/src/APITester.tsx deleted file mode 100644 index fd2af48..0000000 --- a/src/APITester.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useRef, type FormEvent } from "react"; - -export function APITester() { - const responseInputRef = useRef(null); - - const testEndpoint = async (e: FormEvent) => { - e.preventDefault(); - - try { - const form = e.currentTarget; - const formData = new FormData(form); - const endpoint = formData.get("endpoint") as string; - const url = new URL(endpoint, location.href); - const method = formData.get("method") as string; - const res = await fetch(url, { method }); - - const data = await res.json(); - responseInputRef.current!.value = JSON.stringify(data, null, 2); - } catch (error) { - responseInputRef.current!.value = String(error); - } - }; - - return ( -
-
- - - -
-