diff --git a/greenfield/bun.lock b/greenfield/bun.lock index 07d265b40..9284e602a 100644 --- a/greenfield/bun.lock +++ b/greenfield/bun.lock @@ -72,7 +72,9 @@ "drizzle-kit": "1.0.0-rc.4", "eventsource": "4.1.1", "happy-dom": "^20.11.1", + "jsdom-testing-mocks": "^1.16.0", "jsonc-parser": "3.3.1", + "lcov-result-merger": "^6.0.0", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "oxlint-config-presets": "^0.1.18", @@ -246,6 +248,12 @@ "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@otplib/core": ["@otplib/core@13.4.1", "", {}, "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA=="], "@otplib/hotp": ["@otplib/hotp@13.4.1", "", { "dependencies": { "@otplib/core": "13.4.1", "@otplib/uri": "13.4.1" } }, "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA=="], @@ -618,6 +626,10 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="], + "bezier-easing": ["bezier-easing@2.1.0", "", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], @@ -640,6 +652,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -658,6 +672,8 @@ "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + "css-mediaquery": ["css-mediaquery@0.1.2", "", {}, "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -688,6 +704,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], @@ -710,16 +728,28 @@ "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], "fractional-indexing": ["fractional-indexing@3.4.0", "", {}, "sha512-8J3glhz2rrpKG6KmI7wmJo3zH1VjeOpN+vTJSw1fOyO+Viqq3zX6/5NGh6oaZB2qIAYdOYuu5Dz9xp4faOO0Pg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -764,8 +794,14 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -778,6 +814,8 @@ "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jsdom-testing-mocks": ["jsdom-testing-mocks@1.16.0", "", { "dependencies": { "bezier-easing": "^2.1.0", "css-mediaquery": "^0.1.2" } }, "sha512-wLrulXiLpjmcUYOYGEvz4XARkrmdVpyxzdBl9IAMbQ+ib2/UhUTRCn49McdNfXLff2ysGBUms49ZKX0LR1Q0gg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -786,6 +824,8 @@ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "lcov-result-merger": ["lcov-result-merger@6.0.0", "", { "dependencies": { "fast-glob": "^3.3.3", "yargs": "^18.0.0" }, "bin": { "lcov-result-merger": "bin/lcov-result-merger.js" } }, "sha512-RsXi/N4J9b37mu83d3L8WdCGU0jsftN6GlXaJySK6fu+zD48joHC579hpA3K4brPvwWOhbNX5NoT7mghxDi5qw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -858,6 +898,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -916,6 +958,8 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -948,6 +992,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -964,6 +1010,8 @@ "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-aria": ["react-aria@3.51.0", "", { "dependencies": { "@internationalized/date": "^3.12.3", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.49.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ=="], @@ -1010,6 +1058,10 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -1028,8 +1080,12 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -1048,6 +1104,8 @@ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1098,8 +1156,16 @@ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@tanstack/devtools-client/@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.5.0", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA=="], @@ -1128,6 +1194,8 @@ "babel-plugin-react-compiler/@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=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "micromark-extension-frontmatter/fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -1138,8 +1206,14 @@ "solid-js/seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@testing-library/dom/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], diff --git a/greenfield/bunfig.toml b/greenfield/bunfig.toml index 6bed54f64..cc7f4a1be 100644 --- a/greenfield/bunfig.toml +++ b/greenfield/bunfig.toml @@ -2,12 +2,12 @@ peer = false [test] -coverageReporter = ["text", "lcov"] -coverageDir = "coverage" +preload = ["./src/test/setup.ts"] coverageSkipTestFiles = true coveragePathIgnorePatterns = [ "scripts/**", "src/**/*.d.ts", + "src/**/test/**", "src/**/testSupport/**", "src/server/test/**", "src/test/**", diff --git a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 5f69ec8c1..b2225318a 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -55,8 +55,8 @@ environment against the exact candidate binary: transactions, prepared-statement disposal, backup, restore, and process termination. 5. Frontend build tests for HTML imports, Tailwind, React Compiler, lazy chunks, CSP, source maps, cache hashes, precompression, and bundle budgets. -6. `bun test --isolate` tests for fake timers, leaked handles, deterministic shutdown, and - bounded concurrency. +6. Same-process Bun tests for fake timers, leaked handles, deterministic shutdown, and + bounded concurrency, with explicit teardown after every test. The historical 2026-08-06 Phase 0 qualification round passed on exact revision `17d6843606d76620cb55d31424d7fb0aed51c367`: its then-current dedicated typecheck and full evidence @@ -330,7 +330,7 @@ Additional safeguards: - no unbounded `Promise.all` over files, containers, tests, sessions, or API results; - server-side pagination or cursors for every append-only history; - run the Bun and browser type-aware lint partitions sequentially on the VPS; -- cap Bun test concurrency and isolate tests that leak global runtime state; and +- cap Bun test concurrency and require deterministic same-process cleanup of global state; and - record cgroup OOM/limit exits as failed jobs with an actionable message. ## Build, Test, and Quality Tooling @@ -340,8 +340,8 @@ Additional safeguards: The exact naming may change as product areas arrive. The future-root package exposes one `typecheck` gate over the root solution; focused browser and Bun child checks may remain as developer aliases. There are no per-domain, server, worker, script, or qualification TypeScript -projects. `check:boundaries` and `test:boundaries` enforce the finer source roles. Retained Phase 0 -mechanisms run through the ordinary integration, parity, or audit suites. Product and +projects. `check:boundaries` enforces the finer source roles. Retained Phase 0 mechanisms run +through the ordinary Bun test graph. Product and cross-process integration tests live under `src/`; a focused test of a repository script may remain colocated with that script. @@ -351,13 +351,9 @@ build deterministic browser and server/worker artifacts typecheck root TypeScript solution (browser + Bun), no emit lint oxlint type-aware rules and type-check diagnostics, partitioned by runtime format / format:check oxfmt -test:unit pure domain and utility tests -test:database temporary SQLite repository/migration tests -test:contracts tRPC caller, raw HTTP registry, schema, and docs tests -test:realtime SSE/outbox/reconnect/race/backpressure tests -test:frontend Happy DOM + Testing Library behavior tests -test:integration Bun server/worker/Gateway fixture tests -test:parity named current-feature acceptance suite +test Bun graph followed by browser graph +test:bun scripts, server, worker, contracts, integration, and parity tests +test:browser Happy DOM + Testing Library behavior tests test:coverage all tests + 85% executable-source line gate and LCOV docs:generate/check deterministic generated documentation verify sequential local gate with explicit resource caps diff --git a/greenfield/docs/development/testing-and-prs.md b/greenfield/docs/development/testing-and-prs.md index c8ca33e80..13d490fc6 100644 --- a/greenfield/docs/development/testing-and-prs.md +++ b/greenfield/docs/development/testing-and-prs.md @@ -43,7 +43,7 @@ policy. Browser tests are checked by the browser graph with DOM/JSX and the narrow `bun:test` declaration. All remaining tests are included by the Bun graph. Every `*.test.ts(x)`, `*.spec.ts(x)`, -`__tests__/`, and `testSupport/` file must therefore remain type-checked. +`__tests__/`, `test/`, and `testSupport/` file must therefore remain type-checked. ## Test ownership @@ -51,8 +51,10 @@ Keep a module's tests beside that module. If one production module needs multipl suites, use `.test.ts` rather than creating an omnibus suite. - Put reusable executable helpers in the owning module's `testSupport/` directory. +- Keep browser-wide setup and self-contained browser build fixtures under `src/browser/test/`. - Put genuinely cross-domain server harnesses in `src/server/test/support/`. -- Reserve `fixtures/` for immutable payloads and reviewed evidence. +- Reserve `fixtures/` for immutable payloads, reviewed evidence, and self-contained build inputs; + never put reusable helper logic there. - Put cross-module contracts in `src/server/test/contracts/`. - Put composition-root behavior in `src/server/test/system/`. - Keep executable repository audits and tools under `scripts/`. Tests that directly verify one @@ -68,11 +70,12 @@ and additionally fails an otherwise green suite when output contains a React mis warning, an unconfigured React act environment warning, or a Bun panic/crash banner. Do not bypass that runner in repository test scripts. -The browser suite preloads only the Happy DOM globals and React act-environment marker it needs; -the tests themselves remain in the browser TypeScript graph. The product-shell test renders the -real QueryClient, router, accessible route, and error-boundary composition. Build tests separately -exercise the actual HTML entrypoint, React Compiler, Tailwind, code splitting, compression, CSP -policy, and bundle budgets. +Every suite preloads the process-private test root and mock cleanup. The browser suite additionally +preloads Happy DOM, Testing Library matchers and cleanup, the React act-environment marker, and the +Headless UI animation mock. Browser tests and their support remain in the browser TypeScript graph. +The product-shell test renders the real QueryClient, router, accessible route, and error-boundary +composition. Build tests separately exercise the actual HTML entrypoint, React Compiler, Tailwind, +code splitting, compression, CSP policy, and bundle budgets. ## Lint and boundaries diff --git a/greenfield/docs/generated/packages-and-runtime.md b/greenfield/docs/generated/packages-and-runtime.md index 6ed466413..0c3172588 100644 --- a/greenfield/docs/generated/packages-and-runtime.md +++ b/greenfield/docs/generated/packages-and-runtime.md @@ -79,7 +79,9 @@ | `drizzle-kit` | `1.0.0-rc.4` | `1.0.0-rc.4` | development | | `eventsource` | `4.1.1` | `4.1.1` | development | | `happy-dom` | `^20.11.1` | `20.11.1` | development | +| `jsdom-testing-mocks` | `^1.16.0` | `1.16.0` | development | | `jsonc-parser` | `3.3.1` | `3.3.1` | development | +| `lcov-result-merger` | `^6.0.0` | `6.0.0` | development | | `oxfmt` | `^0.62.0` | `0.62.0` | development | | `oxlint` | `^1.77.0` | `1.77.0` | development | | `oxlint-config-presets` | `^0.1.18` | `0.1.18` | development | diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index f15d215f4..1a7909978 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -26,6 +26,8 @@ | `auth.loginTotp` | mutation | auth | Pending MFA login | [input](./schemas/auth.loginTotp.input.schema.json) | [output](./schemas/auth.loginTotp.output.schema.json) | `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Consumes a pending login with a TOTP proof and creates the browser session. | | `auth.loginWebAuthn` | mutation | auth | Pending MFA login | [input](./schemas/auth.loginWebAuthn.input.schema.json) | [output](./schemas/auth.loginWebAuthn.output.schema.json) | `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Consumes a pending login and WebAuthn challenge to create the browser session. | | `auth.logout` | mutation | auth | Public | [input](./schemas/auth.logout.input.schema.json) | [output](./schemas/auth.logout.output.schema.json) | `SERVICE_UNAVAILABLE` | None | Revokes current session and pending-login state and clears both cookies. | +| `auth.revokeAllSessions` | mutation | auth | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.revokeAllSessions.input.schema.json) | [output](./schemas/auth.revokeAllSessions.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `step_up_required` | Revokes every browser session, including the current session. | +| `auth.revokeOtherSessions` | mutation | auth | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.revokeOtherSessions.input.schema.json) | [output](./schemas/auth.revokeOtherSessions.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `step_up_required` | Revokes every browser session except the current session. | | `auth.revokeSession` | mutation | auth | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.revokeSession.input.schema.json) | [output](./schemas/auth.revokeSession.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `step_up_required` | Revokes one browser session owned by the current user. | | `auth.sessions` | query | auth | Authenticated browser session | [input](./schemas/auth.sessions.input.schema.json) | [output](./schemas/auth.sessions.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists the current user's browser sessions without validators. | | `auth.status` | query | auth | Public | [input](./schemas/auth.status.input.schema.json) | [output](./schemas/auth.status.output.schema.json) | None | None | Returns bootstrap, pending MFA, and current browser-session state. | @@ -39,4 +41,5 @@ | `automationSecurity.revokeCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.revokeCredential.input.schema.json) | [output](./schemas/automationSecurity.revokeCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Explicitly revokes one automation credential after client cutover. | | `automationSecurity.rotateCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.rotateCredential.input.schema.json) | [output](./schemas/automationSecurity.rotateCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Stages a linked replacement credential without revoking its predecessor. | | `events.stream` | subscription | events | Authenticated; per-topic: notifications:read, reports:read | [input](./schemas/events.stream.input.schema.json) | [output](./schemas/events.stream.output.schema.json) | `BAD_REQUEST`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Streams authorized durable changes with tracked resume cursors. | +| `securityAudit.listEvents` | query | securityAudit | Authenticated browser session | [input](./schemas/securityAudit.listEvents.input.schema.json) | [output](./schemas/securityAudit.listEvents.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists redacted immutable security events in stable newest-first order. | | `system.runtimeIdentity` | query | system | Public | [input](./schemas/system.runtimeIdentity.input.schema.json) | [output](./schemas/system.runtimeIdentity.output.schema.json) | None | None | Returns the Bun runtime identity of the serving process. | diff --git a/greenfield/docs/generated/schemas/auth.revokeAllSessions.input.schema.json b/greenfield/docs/generated/schemas/auth.revokeAllSessions.input.schema.json new file mode 100644 index 000000000..4b25907f9 --- /dev/null +++ b/greenfield/docs/generated/schemas/auth.revokeAllSessions.input.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "urn:mira-dashboard:auth.revokeAllSessions.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/auth.revokeAllSessions.output.schema.json b/greenfield/docs/generated/schemas/auth.revokeAllSessions.output.schema.json new file mode 100644 index 000000000..36904385b --- /dev/null +++ b/greenfield/docs/generated/schemas/auth.revokeAllSessions.output.schema.json @@ -0,0 +1,16 @@ +{ + "$id": "urn:mira-dashboard:auth.revokeAllSessions.output", + "type": "object", + "properties": { + "revokedSessions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "revokedSessions" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/auth.revokeOtherSessions.input.schema.json b/greenfield/docs/generated/schemas/auth.revokeOtherSessions.input.schema.json new file mode 100644 index 000000000..8d84399b8 --- /dev/null +++ b/greenfield/docs/generated/schemas/auth.revokeOtherSessions.input.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "urn:mira-dashboard:auth.revokeOtherSessions.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/auth.revokeOtherSessions.output.schema.json b/greenfield/docs/generated/schemas/auth.revokeOtherSessions.output.schema.json new file mode 100644 index 000000000..74d4ec2db --- /dev/null +++ b/greenfield/docs/generated/schemas/auth.revokeOtherSessions.output.schema.json @@ -0,0 +1,16 @@ +{ + "$id": "urn:mira-dashboard:auth.revokeOtherSessions.output", + "type": "object", + "properties": { + "revokedSessions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "revokedSessions" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/securityAudit.listEvents.input.schema.json b/greenfield/docs/generated/schemas/securityAudit.listEvents.input.schema.json new file mode 100644 index 000000000..576ffaa6b --- /dev/null +++ b/greenfield/docs/generated/schemas/securityAudit.listEvents.input.schema.json @@ -0,0 +1,37 @@ +{ + "$id": "urn:mira-dashboard:securityAudit.listEvents.input", + "type": "object", + "properties": { + "cursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "occurredAtMs" + ], + "additionalProperties": false + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 20 + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json new file mode 100644 index 000000000..bb2071f53 --- /dev/null +++ b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json @@ -0,0 +1,314 @@ +{ + "$id": "urn:mira-dashboard:securityAudit.listEvents.output", + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "actor": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + } + ] + }, + "kind": { + "const": "anonymous" + } + }, + "required": [ + "id", + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "authenticatorId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "kind": { + "const": "automation" + } + }, + "required": [ + "authenticatorId", + "id", + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + } + ] + }, + "kind": { + "const": "system" + } + }, + "required": [ + "id", + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "authenticatorId": { + "type": "string", + "minLength": 32, + "maxLength": 32, + "pattern": "^[0-9a-f]{32}$" + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "const": "user" + } + }, + "required": [ + "authenticatorId", + "id", + "kind" + ], + "additionalProperties": false + } + ] + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "metadata": { + "type": "object", + "properties": { + "addedCapabilities": { + "type": "array", + "items": { + "enum": [ + "notifications:read", + "reports:read" + ], + "type": "string" + }, + "maxItems": 2, + "uniqueItems": true + }, + "method": { + "enum": [ + "password", + "recovery", + "totp", + "webauthn" + ], + "type": "string" + }, + "pendingMfa": { + "type": "boolean" + }, + "predecessorCredentialId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "reason": { + "enum": [ + "gateway_unavailable", + "identity_changed", + "invalid_credentials", + "invalid_current_password", + "invalid_gateway", + "recovery_invalid", + "recovery_pending_invalid", + "totp_invalid", + "totp_pending_invalid", + "webauthn_configuration_mismatch", + "webauthn_invalid", + "webauthn_pending_invalid" + ], + "type": "string" + }, + "removedCapabilities": { + "type": "array", + "items": { + "enum": [ + "notifications:read", + "reports:read" + ], + "type": "string" + }, + "maxItems": 2, + "uniqueItems": true + }, + "replacementCredentialId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "revoked": { + "type": "boolean" + }, + "revokedCredentials": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "revokedSessions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [], + "additionalProperties": false + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "outcome": { + "enum": [ + "accepted", + "attempted", + "cancelled", + "denied", + "failed", + "succeeded" + ], + "type": "string" + }, + "requestId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + } + ] + }, + "target": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + } + ] + }, + "type": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + } + }, + "required": [ + "id", + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "action", + "actor", + "id", + "metadata", + "occurredAtMs", + "outcome", + "target" + ], + "additionalProperties": false + }, + "maxItems": 50, + "$comment": "Live Valibot validation additionally requires strict newest-first audit-event ordering by occurrence timestamp and ID." + }, + "nextCursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "occurredAtMs" + ], + "additionalProperties": false + } + }, + "required": [ + "events" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires an audit continuation cursor to identify the returned last event.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/security/greenfield-phase-two-threat-model.md b/greenfield/docs/security/greenfield-phase-two-threat-model.md index ce06e2d87..3d1517614 100644 --- a/greenfield/docs/security/greenfield-phase-two-threat-model.md +++ b/greenfield/docs/security/greenfield-phase-two-threat-model.md @@ -155,8 +155,8 @@ Phase 2 is closed only for the server-side scope stated above. The evidence cons 1. focused unit, repository, lifecycle, adapter, and system tests at the exact paths in the misuse table; -2. `bun run typecheck`, `bun run test:server`, and the security-relevant integration suites; -3. `bun run test:tooling`, `bun run docs:check`, and `bun run db:check`; and +2. `bun run typecheck`, `bun run test:bun`, and `bun run test:browser`; +3. `bun run test:coverage`, `bun run docs:check`, and `bun run db:check`; and 4. the explicit remaining-phase status in `docs/architecture/greenfield-rewrite/progress.md`. diff --git a/greenfield/drizzle.config.ts b/greenfield/drizzle.config.ts index 6fb165c54..4326c1b38 100644 --- a/greenfield/drizzle.config.ts +++ b/greenfield/drizzle.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ breakpoints: true, dbCredentials: { + // Drizzle Kit requires a SQLite target even for schema-only commands. Runtime + // database paths are resolved separately; this ignored path is tooling-only. url: "./data/drizzle-kit.db", }, dialect: "sqlite", diff --git a/greenfield/package.json b/greenfield/package.json index 672af6289..8593fce45 100644 --- a/greenfield/package.json +++ b/greenfield/package.json @@ -22,15 +22,10 @@ "lint:bun": "oxlint . --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**'", "lint:fix": "oxlint . --fix --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**' && oxlint src/browser --fix --tsconfig tsconfig.browser.json --no-error-on-unmatched-pattern", "evidence:resources:sse": "bun src/test/integration/resources/runSseMemoryEvidence.ts", - "test": "bun run test:boundaries && bun run test:browser && bun run test:delivery && bun run test:integration && bun run test:parity && bun run test:server && bun run test:tooling", - "test:boundaries": "bun scripts/runTestSuite.ts scripts/sourceBoundaries", - "test:browser": "bun scripts/runTestSuite.ts --preload ./src/browser/testSupport/browserTestPreload.ts src/browser", + "test": "bun run test:bun && bun run test:browser", + "test:browser": "bun scripts/runTestSuite.ts --preload ./src/browser/test/setup.ts src/browser", + "test:bun": "bun scripts/runTestSuite.ts scripts src/app src/contracts src/server src/shared src/test src/worker", "test:coverage": "bun scripts/runCoverage.ts", - "test:delivery": "bun scripts/runTestSuite.ts scripts/delivery", - "test:integration": "bun scripts/runTestSuite.ts src/test/integration src/test/support", - "test:parity": "bun scripts/runTestSuite.ts src/test/parity", - "test:server": "bun scripts/runTestSuite.ts src/app src/server src/shared src/contracts", - "test:tooling": "bun scripts/runTestSuite.ts scripts/documentation scripts/buildSourceIdentity.test.ts scripts/checkDatabaseSchema.test.ts scripts/checkCoverage.test.ts scripts/packageIdentity.test.ts scripts/runCoverage.test.ts scripts/runTestSuite.test.ts scripts/testOutputPolicy.test.ts", "typecheck": "bun run typecheck:browser && bun run typecheck:bun", "typecheck:browser": "bun node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit", "typecheck:bun": "bun node_modules/typescript/bin/tsc -p tsconfig.bun.json --noEmit" @@ -103,7 +98,9 @@ "drizzle-kit": "1.0.0-rc.4", "eventsource": "4.1.1", "happy-dom": "^20.11.1", + "jsdom-testing-mocks": "^1.16.0", "jsonc-parser": "3.3.1", + "lcov-result-merger": "^6.0.0", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "oxlint-config-presets": "^0.1.18", diff --git a/greenfield/scripts/delivery/buildAdmission.test.ts b/greenfield/scripts/delivery/buildAdmission.test.ts index 2c0830a3e..30ad85753 100644 --- a/greenfield/scripts/delivery/buildAdmission.test.ts +++ b/greenfield/scripts/delivery/buildAdmission.test.ts @@ -27,26 +27,37 @@ describe("Bun build admission", () => { const repositoryRoot = await repositoryFixture(); const events: string[] = []; let releaseFirst!: () => void; + let markFirstStarted!: () => void; const firstMayFinish = new Promise((resolve) => { releaseFirst = resolve; }); + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); const first = withBunBuildAdmission(repositoryRoot, async () => { events.push("first-start"); + markFirstStarted(); await firstMayFinish; events.push("first-end"); }); - await Bun.sleep(20); - const second = withBunBuildAdmission(repositoryRoot, () => { - events.push("second"); - return Promise.resolve(); - }); - await Bun.sleep(20); + let second: Promise | undefined; + try { + await firstStarted; + second = withBunBuildAdmission(repositoryRoot, () => { + events.push("second"); + return Promise.resolve(); + }); + await Bun.sleep(20); - expect(events).toEqual(["first-start"]); - releaseFirst(); - await Promise.all([first, second]); - expect(events).toEqual(["first-start", "first-end", "second"]); + expect(events).toEqual(["first-start"]); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(["first-start", "first-end", "second"]); + } finally { + releaseFirst(); + await Promise.allSettled([first, ...(second === undefined ? [] : [second])]); + } }); test("recovers a validated dead-owner lock and rejects malformed lock data", async () => { diff --git a/greenfield/scripts/delivery/deploymentLease.test.ts b/greenfield/scripts/delivery/deploymentLease.test.ts index 29d589eea..6d620bf35 100644 --- a/greenfield/scripts/delivery/deploymentLease.test.ts +++ b/greenfield/scripts/delivery/deploymentLease.test.ts @@ -38,13 +38,15 @@ describe("Dashboard deployment lease", () => { const state = await stateFixture(); const events: string[] = []; const firstMayFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); const first = withDeploymentLease(state, async () => { events.push("first-start"); + firstStarted.resolve(); await firstMayFinish.promise; events.push("first-end"); }); - await Bun.sleep(30); + await firstStarted.promise; const second = withDeploymentLease(state, () => { events.push("second"); return Promise.resolve(); @@ -61,6 +63,10 @@ describe("Dashboard deployment lease", () => { const state = await stateFixture(); const lockPath = path.join(state, ".deployment.lock"); const initializingLock = await open(lockPath, "wx", 0o600); + await initializingLock.writeFile( + JSON.stringify({ pid: process.pid, token: Bun.randomUUIDv7() }), + "utf8" + ); let entered = false; const transition = withDeploymentLease(state, () => { @@ -70,6 +76,11 @@ describe("Dashboard deployment lease", () => { await Bun.sleep(50); expect(entered).toBe(false); + await initializingLock.write("\n"); + await initializingLock.sync(); + await Bun.sleep(50); + expect(entered).toBe(false); + await initializingLock.close(); await unlink(lockPath); await transition; diff --git a/greenfield/scripts/delivery/exclusiveProcessLock.ts b/greenfield/scripts/delivery/exclusiveProcessLock.ts index f6f21e5af..5739f018f 100644 --- a/greenfield/scripts/delivery/exclusiveProcessLock.ts +++ b/greenfield/scripts/delivery/exclusiveProcessLock.ts @@ -97,12 +97,13 @@ function sameSnapshot( async function processLockMayStillBeInitializing( lockPath: string, failureMessage: string, - contents: string | undefined + contents: string | undefined, + observedMutation: boolean ): Promise { // O_EXCL publishes the pathname before the owner can finish its bounded record. // A newline terminates every complete record, so completed malformed data must // fail immediately while only a secure incomplete publication receives grace. - if (contents?.endsWith("\n")) return false; + if (!observedMutation && contents?.endsWith("\n")) return false; let status: BigIntStats; try { status = await lstat(lockPath, { bigint: true }); @@ -167,6 +168,7 @@ async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< > { let handle: FileHandle; let contents: string | undefined; + let observedMutation = false; try { handle = await open(options.lockPath, lockReadFlags); } catch (error) { @@ -174,21 +176,33 @@ async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< throw processLockFailure(options.failureMessage); } try { - const before = snapshot( - await handle.stat({ bigint: true }), - options.failureMessage - ); + const beforeStatus = await handle.stat({ bigint: true }); + if (beforeStatus.nlink === 0n) return undefined; + const before = snapshot(beforeStatus, options.failureMessage); contents = await handle.readFile("utf8"); - const after = snapshot( - await handle.stat({ bigint: true }), - options.failureMessage - ); + const afterStatus = await handle.stat({ bigint: true }); + if (afterStatus.nlink === 0n) return undefined; + const after = snapshot(afterStatus, options.failureMessage); + if (!sameSnapshot(before, after)) { + observedMutation = true; + throw processLockFailure(options.failureMessage); + } if ( - !sameSnapshot(before, after) || + !contents.endsWith("\n") || Buffer.byteLength(contents) > maximumProcessLockBytes ) { throw processLockFailure(options.failureMessage); } + let pathStatus: BigIntStats; + try { + pathStatus = await lstat(options.lockPath, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined; + throw processLockFailure(options.failureMessage); + } + if (!sameSnapshot(after, snapshot(pathStatus, options.failureMessage))) { + return undefined; + } const parsed: unknown = JSON.parse(contents); return Object.freeze({ owner: v.parse(processLockOwnerSchema, parsed), @@ -199,7 +213,8 @@ async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< await processLockMayStillBeInitializing( options.lockPath, options.failureMessage, - contents + contents, + observedMutation ) ) { return undefined; diff --git a/greenfield/scripts/delivery/productionReleaseActivation.test.ts b/greenfield/scripts/delivery/productionReleaseActivation.test.ts index 17ef4d1da..b68937a79 100644 --- a/greenfield/scripts/delivery/productionReleaseActivation.test.ts +++ b/greenfield/scripts/delivery/productionReleaseActivation.test.ts @@ -293,8 +293,35 @@ describe("production release activation", () => { expect(await readdir(paths.stateDirectory)).not.toContain( "activation-transition.json" ); + }); + }); - services.rejectReadyReleaseId = undefined; + test("restores the previous release and database after a partial candidate start", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const dependencies = activationDependencies(services, fixtures.probeRuntime); + const initial = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); services.rejectStartReleaseId = secondReleaseId; const partialStartFailure = await rejectionError( Effect.runPromise( @@ -315,6 +342,11 @@ describe("production release activation", () => { "Production release activation failed" ); expect(activationAfterPartialStart.record).toEqual(initial); + expect( + readMigrationReleaseId( + path.join(paths.stateDirectory, "mira-dashboard.db") + ) + ).toBe(firstReleaseId); expect(services.events.slice(-9)).toEqual([ `prepare:${firstReleaseId}`, "stop", @@ -326,6 +358,9 @@ describe("production release activation", () => { `start:${firstReleaseId}`, `ready:${firstReleaseId}`, ]); + expect(await readdir(paths.stateDirectory)).not.toContain( + "activation-transition.json" + ); }); }); diff --git a/greenfield/scripts/documentation/jsonSchema.test.ts b/greenfield/scripts/documentation/jsonSchema.test.ts index 57afa323b..4b0d1a447 100644 --- a/greenfield/scripts/documentation/jsonSchema.test.ts +++ b/greenfield/scripts/documentation/jsonSchema.test.ts @@ -21,11 +21,15 @@ import { listAutomationPrincipalsResultSchema, } from "../../src/contracts/automationSecurity.ts"; import { applicationCapabilityListSchema } from "../../src/contracts/security.ts"; +import { listSecurityAuditEventsResultSchema } from "../../src/contracts/securityAudit.ts"; import { webAuthnAuthenticationResponseSchema, webAuthnTransportListSchema, } from "../../src/contracts/webauthn.ts"; -import { hasUniqueArrayItems } from "../../src/shared/validation.ts"; +import { + boundedNonBlankTextSchema, + hasUniqueArrayItems, +} from "../../src/shared/validation.ts"; import { convertContractSchema } from "./jsonSchema.ts"; const parseHexadecimalCodePoint = (value: string): number => Number.parseInt(value, 16); @@ -181,6 +185,35 @@ describe("contract JSON Schema conversion", () => { }); }); + test("documents persistence-safe bounded text without losing constraints", () => { + expect( + convertContractSchema( + boundedNonBlankTextSchema(4), + "test.boundedText", + "input" + ) + ).toMatchObject({ + allOf: [{ pattern: "^[^\\u0000]*$" }], + maxLength: 4, + minLength: 1, + pattern: "\\S", + type: "string", + }); + }); + + test("documents security audit ordering and cursor refinements", () => { + const document = JSON.stringify( + convertContractSchema( + listSecurityAuditEventsResultSchema, + "test.securityAuditPage", + "output" + ) + ); + + expect(document).toContain("strict newest-first audit-event ordering"); + expect(document).toContain("audit continuation cursor"); + }); + test("documents the exact TOTP factor-label predicate", () => { const document = convertContractSchema( totpFactorLabelSchema, diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index 490999bca..a19037652 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -31,13 +31,21 @@ import { securityLabelMaximumLength, sortApplicationCapabilities, } from "../../src/contracts/security.ts"; +import { + securityAuditEventsHaveStableOrder, + securityAuditPageCursorIsConsistent, +} from "../../src/contracts/securityAudit.ts"; import { hasMatchingWebAuthnAuthenticationCredentialIds, hasMatchingWebAuthnRegistrationCredentialIds, isCanonicalWebAuthnBase64Url, sortWebAuthnTransports, } from "../../src/contracts/webauthn.ts"; -import { hasUniqueArrayItems } from "../../src/shared/validation.ts"; +import { + getBoundedNonBlankTextMaximumLength, + hasNoNulCharacter, + hasUniqueArrayItems, +} from "../../src/shared/validation.ts"; /** JSON Schema conversion direction for transport schemas. */ export type SchemaTypeMode = "input" | "output"; @@ -54,8 +62,9 @@ const securityLabelControlOrFormatPattern = [ String.raw`\uDB40(?:\uDC01|[\uDC20-\uDC7F])`, ].join("|"); const securityLabelJsonSchemaPattern = `^(?=[\\s\\S]*\\S)(?![\\s\\S]*(?:${securityLabelControlOrFormatPattern}))[\\s\\S]+$`; +const noNulJsonSchemaPattern = String.raw`^[^\u0000]*$`; -const automationRuntimeCheckComments = new Map([ +const runtimeCheckComments = new Map([ [ automationCredentialTimesAreOrdered, "Live Valibot validation additionally requires credential expiry after creation and revocation no earlier than creation.", @@ -116,6 +125,14 @@ const automationRuntimeCheckComments = new Map([ disabledAutomationPrincipalResultIsConsistent, "Live Valibot validation additionally requires terminal disabled state and zero newly revoked credentials for an idempotent no-op.", ], + [ + securityAuditEventsHaveStableOrder, + "Live Valibot validation additionally requires strict newest-first audit-event ordering by occurrence timestamp and ID.", + ], + [ + securityAuditPageCursorIsConsistent, + "Live Valibot validation additionally requires an audit continuation cursor to identify the returned last event.", + ], ]); function appendJsonSchemaComment( @@ -135,6 +152,26 @@ function appendJsonSchemaComment( }; } +function appendJsonSchemaPattern( + jsonSchema: object, + pattern: string +): Record { + const existingPattern = + "pattern" in jsonSchema && typeof jsonSchema.pattern === "string" + ? jsonSchema.pattern + : undefined; + if (existingPattern === undefined) return { ...jsonSchema, pattern }; + + const existingAllOfValue: unknown = Reflect.get(jsonSchema, "allOf"); + const existingAllOf: readonly unknown[] = Array.isArray(existingAllOfValue) + ? (existingAllOfValue as unknown[]) + : []; + return { + ...jsonSchema, + allOf: [...existingAllOf, { pattern }], + }; +} + function readActionRequirement(action: unknown): unknown { return typeof action === "object" && action !== null && "requirement" in action ? (action as Record).requirement @@ -219,6 +256,20 @@ export function convertContractSchema( ) { return { ...jsonSchema, uniqueItems: true }; } + if (valibotAction.type === "check" && requirement === hasNoNulCharacter) { + return appendJsonSchemaPattern(jsonSchema, noNulJsonSchemaPattern); + } + if (valibotAction.type === "check") { + const maximumLength = + getBoundedNonBlankTextMaximumLength(requirement); + if (maximumLength !== undefined) { + return { + ...jsonSchema, + maxLength: maximumLength, + minLength: 1, + }; + } + } if ( valibotAction.type === "check" && requirement === isCanonicalWebAuthnBase64Url @@ -254,7 +305,7 @@ export function convertContractSchema( }; } if (valibotAction.type === "check") { - const comment = automationRuntimeCheckComments.get(requirement); + const comment = runtimeCheckComments.get(requirement); if (comment !== undefined) { return appendJsonSchemaComment(jsonSchema, comment); } diff --git a/greenfield/scripts/frontendBuildArtifacts.ts b/greenfield/scripts/frontendBuildArtifacts.ts index 252ae1f53..245cd8a46 100644 --- a/greenfield/scripts/frontendBuildArtifacts.ts +++ b/greenfield/scripts/frontendBuildArtifacts.ts @@ -51,7 +51,7 @@ type FrontendBundleBudget = keyof Pick< export const FRONTEND_BUNDLE_BUDGETS: Readonly> = { initialJavaScriptGzipBytes: 350 * 1024, initialStylesheetGzipBytes: 25 * 1024, - largestJavaScriptGzipBytes: 75 * 1024, + largestJavaScriptGzipBytes: 200 * 1024, totalJavaScriptGzipBytes: 850 * 1024, }; diff --git a/greenfield/scripts/runCoverage.test.ts b/greenfield/scripts/runCoverage.test.ts index b9c499dfa..aa65337eb 100644 --- a/greenfield/scripts/runCoverage.test.ts +++ b/greenfield/scripts/runCoverage.test.ts @@ -3,19 +3,37 @@ import { describe, expect, test } from "bun:test"; import { createCoverageTestArguments } from "./runCoverage.ts"; describe("coverage runner", () => { - test("runs every test target in one coverage process without a global DOM", () => { - const arguments_ = createCoverageTestArguments("/tmp/coverage-output"); + test("keeps Bun coverage free of browser globals", () => { + const arguments_ = createCoverageTestArguments("/tmp/coverage-output", "bun"); expect(arguments_).toEqual([ "--coverage", "--coverage-reporter", - "text", - "--coverage-reporter", "lcov", "--coverage-dir", "/tmp/coverage-output", "scripts", - "src", + "src/app", + "src/contracts", + "src/server", + "src/shared", + "src/test", + "src/worker", + ]); + }); + + test("loads the DOM setup only for browser coverage", () => { + const arguments_ = createCoverageTestArguments("/tmp/coverage-output", "browser"); + + expect(arguments_).toEqual([ + "--coverage", + "--coverage-reporter", + "lcov", + "--coverage-dir", + "/tmp/coverage-output", + "--preload", + "./src/browser/test/setup.ts", + "src/browser", ]); }); }); diff --git a/greenfield/scripts/runCoverage.ts b/greenfield/scripts/runCoverage.ts index a6abafa5c..610ab38be 100644 --- a/greenfield/scripts/runCoverage.ts +++ b/greenfield/scripts/runCoverage.ts @@ -1,6 +1,8 @@ -import { mkdir, unlink } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +import { mergeCoverageReportFiles } from "lcov-result-merger"; + import { checkCoverageFile, requiredLineCoveragePercent } from "./checkCoverage.ts"; import { runTestSuite } from "./runTestSuite.ts"; @@ -8,35 +10,58 @@ const projectRoot = path.resolve(import.meta.dir, ".."); const coverageDirectory = path.join(projectRoot, "coverage"); const lcovPath = path.join(coverageDirectory, "lcov.info"); const coveredSourceRoots = Object.freeze(["src"]); -const coverageTestTargets = Object.freeze(["scripts", "src"]); + +export type CoveragePartition = "browser" | "bun"; + +const coveragePartitions = Object.freeze([ + Object.freeze({ name: "bun", outputDirectoryName: "bun" }), + Object.freeze({ name: "browser", outputDirectoryName: "browser" }), +] satisfies readonly Readonly<{ + name: CoveragePartition; + outputDirectoryName: string; +}>[]); /** * Builds the exact Bun test arguments used by the coverage gate. * @param outputDirectory Directory where Bun writes coverage artifacts. + * @param partition Runtime partition whose tests and preload policy are selected. * @returns Complete arguments after `bun test`. */ -export function createCoverageTestArguments(outputDirectory: string): readonly string[] { - return Object.freeze([ +export function createCoverageTestArguments( + outputDirectory: string, + partition: CoveragePartition +): readonly string[] { + const coverageArguments = [ "--coverage", "--coverage-reporter", - "text", - "--coverage-reporter", "lcov", "--coverage-dir", outputDirectory, - ...coverageTestTargets, + ]; + if (partition === "browser") { + return Object.freeze([ + ...coverageArguments, + "--preload", + "./src/browser/test/setup.ts", + "src/browser", + ]); + } + return Object.freeze([ + ...coverageArguments, + "scripts", + "src/app", + "src/contracts", + "src/server", + "src/shared", + "src/test", + "src/worker", ]); } -/** @returns Completion after the exact stale LCOV artifact is absent. */ -async function removeStaleLcov(): Promise { - try { - await unlink(lcovPath); - } catch (error) { - if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { - throw error; - } - } +/** @returns Completion after coverage output has one fresh private directory. */ +async function resetCoverageDirectory(): Promise { + await rm(coverageDirectory, { force: true, recursive: true }); + await mkdir(coverageDirectory, { mode: 0o700, recursive: true }); } /** @@ -44,14 +69,29 @@ async function removeStaleLcov(): Promise { * @returns Zero when tests, output policy, and line coverage all pass. */ export async function runCoverage(): Promise { - await mkdir(coverageDirectory, { recursive: true }); - await removeStaleLcov(); + await resetCoverageDirectory(); - const testExitCode = await runTestSuite( - createCoverageTestArguments(coverageDirectory), - projectRoot - ); - if (testExitCode !== 0) return testExitCode; + const partitionReports: string[] = []; + for (const partition of coveragePartitions) { + const outputDirectory = path.join( + coverageDirectory, + partition.outputDirectoryName + ); + const testExitCode = await runTestSuite( + createCoverageTestArguments(outputDirectory, partition.name), + projectRoot + ); + if (testExitCode !== 0) return testExitCode; + partitionReports.push(path.join(outputDirectory, "lcov.info")); + } + + const mergedCoverage = await mergeCoverageReportFiles(partitionReports, { + pattern: "", + }); + await writeFile(lcovPath, `${mergedCoverage}\n`, { + encoding: "utf8", + mode: 0o600, + }); const summary = await checkCoverageFile( lcovPath, diff --git a/greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts b/greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts index 669aa092b..87171bf26 100644 --- a/greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts +++ b/greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts @@ -61,6 +61,7 @@ const reviewedTypeScriptConfigurations: Readonly< exclude: ["node_modules"], extends: "./tsconfig.json", files: [ + "node_modules/@testing-library/jest-dom/types/bun.d.ts", "node_modules/bun-types/test.d.ts", "src/test/types/bunCanaryMatchers.d.ts", ], diff --git a/greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts b/greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts index 0ae272b2f..5317cbb48 100644 --- a/greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts +++ b/greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts @@ -85,8 +85,9 @@ describe("effective source-boundary lint configuration", () => { repositoryRoot, "src", "browser", - "testSupport", - "frontendBuildFixture", + "test", + "fixtures", + "frontendBuild", "src", "index.css" ), @@ -110,6 +111,26 @@ describe("effective source-boundary lint configuration", () => { path.join(fixtureRoot, "src", "worker", "workerConsole.ts"), 'console.log("forbidden");\n' ); + const testFixtureSource = + 'import { memo } from "react";\nimport { privateServerValue } from "../server/privateServer.ts";\nexport const testBoundary = [memo, privateServerValue] as const;\n'; + await writeFile( + path.join(fixtureRoot, "src", "browser", "browserBoundary.spec.ts"), + testFixtureSource + ); + await mkdir(path.join(fixtureRoot, "src", "browser", "__tests__")); + await writeFile( + path.join( + fixtureRoot, + "src", + "browser", + "__tests__", + "browserBoundary.ts" + ), + testFixtureSource.replace( + '"../server/privateServer.ts"', + '"../../server/privateServer.ts"' + ) + ); const executable = path.join( repositoryRoot, @@ -120,6 +141,8 @@ describe("effective source-boundary lint configuration", () => { const [browserResult, workerResult] = await Promise.all([ runOxlint(executable, fixtureRoot, "tsconfig.browser.json", [ "src/browser/browserBoundary.ts", + "src/browser/browserBoundary.spec.ts", + "src/browser/__tests__/browserBoundary.ts", ]), runOxlint(executable, fixtureRoot, "tsconfig.bun.json", [ "src/worker/workerConsole.ts", @@ -137,44 +160,8 @@ describe("effective source-boundary lint configuration", () => { ); expect(result.output).toContain("no-implied-eval"); expect(result.output).toContain("no-console"); - - const testFixtureSource = - 'import { memo } from "react";\nimport { privateServerValue } from "../server/privateServer.ts";\nexport const testBoundary = [memo, privateServerValue] as const;\n'; - await writeFile( - path.join(fixtureRoot, "src", "browser", "browserBoundary.spec.ts"), - testFixtureSource - ); - await mkdir(path.join(fixtureRoot, "src", "browser", "__tests__")); - await writeFile( - path.join( - fixtureRoot, - "src", - "browser", - "__tests__", - "browserBoundary.ts" - ), - testFixtureSource.replace( - '"../server/privateServer.ts"', - '"../../server/privateServer.ts"' - ) - ); - const testResult = await runOxlint( - executable, - fixtureRoot, - "tsconfig.browser.json", - [ - "src/browser/browserBoundary.spec.ts", - "src/browser/__tests__/browserBoundary.ts", - ] - ); - - expect(testResult.exitCode).toBe(0); - expect(testResult.output).not.toContain( - "'memo' import from 'react' is restricted" - ); - expect(testResult.output).not.toContain("no-implied-eval"); - expect(testResult.output).not.toContain("no-restricted-imports"); - expect(testResult.output).not.toContain("no-console"); + expect(result.output).not.toContain("browserBoundary.spec.ts"); + expect(result.output).not.toContain("__tests__/browserBoundary.ts"); } finally { await rm(fixtureRoot, { force: true, recursive: true }); } diff --git a/greenfield/scripts/testOutputPolicy.test.ts b/greenfield/scripts/testOutputPolicy.test.ts index 313f09ea3..7df047b20 100644 --- a/greenfield/scripts/testOutputPolicy.test.ts +++ b/greenfield/scripts/testOutputPolicy.test.ts @@ -14,6 +14,11 @@ describe("test output policy", () => { "The current testing environment is not configured to support act(...)", "React act environment is not configured", ], + [ + "Headless UI animations fallback", + "Headless UI has polyfilled `Element.prototype.getAnimations` for your tests.", + "Headless UI Web Animations test shim is missing", + ], [ "Bun main-thread panic", "panic(main thread): assertion failed", diff --git a/greenfield/scripts/testOutputPolicy.ts b/greenfield/scripts/testOutputPolicy.ts index 96b705c44..0b4bdfde2 100644 --- a/greenfield/scripts/testOutputPolicy.ts +++ b/greenfield/scripts/testOutputPolicy.ts @@ -16,6 +16,10 @@ const testOutputRules: readonly TestOutputRule[] = Object.freeze([ description: "React act environment is not configured", pattern: /current testing environment is not configured to support act/i, }, + { + description: "Headless UI Web Animations test shim is missing", + pattern: /Headless UI has polyfilled `Element\.prototype\.getAnimations`/i, + }, { description: "Bun main thread panicked", pattern: /panic\(main thread\):/i, diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 12b1ed25a..d07f533ca 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -27,6 +27,8 @@ import { createWebAuthnAdapter } from "../server/domains/security/mfa/webauthn/a import type { WebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; import { createRequestAuthenticator } from "../server/domains/security/requestAuthentication.ts"; import { createRequestAuthenticationRepository } from "../server/domains/security/requestAuthenticationRepository.ts"; +import { createSecurityAuditLifecycleService } from "../server/domains/security/securityAuditLifecycle.ts"; +import { createSecurityAuditLifecycleRepository } from "../server/domains/security/securityAuditLifecycleRepository.ts"; import { type WebConfiguration, parseWebConfiguration, @@ -76,6 +78,7 @@ export interface DashboardServerOptions extends Omit< | "hostname" | "mfaAccountLifecycle" | "mfaLoginLifecycle" + | "securityAuditLifecycle" > { readonly applicationRuntime: DashboardApplicationRuntime; readonly authenticationLeaseDurationMs?: number; @@ -231,6 +234,11 @@ export async function createDashboardServer( repository: createAutomationLifecycleRepository(database, databaseRuntime), sessionIdleDurationMs: options.sessionIdleDurationMs, }); + const securityAuditLifecycle = createSecurityAuditLifecycleService({ + ...(options.now !== undefined && { now: options.now }), + repository: createSecurityAuditLifecycleRepository(database), + sessionIdleDurationMs: options.sessionIdleDurationMs, + }); const serverOptions: ServerOptions = { applicationRuntime: options.applicationRuntime, authenticateCredential: (credential) => @@ -245,6 +253,7 @@ export async function createDashboardServer( mfaLoginLifecycle, port: options.port, readiness: options.readiness, + securityAuditLifecycle, trustedProxyAddresses: options.trustedProxyAddresses, }; serverOwnsRuntimeCleanup = true; diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index 3e864266b..39ad205eb 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -6,6 +6,7 @@ import type { AuthenticationLifecycleService } from "../server/domains/security/ import type { AutomationSecurityLifecycleService } from "../server/domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../server/domains/security/mfa/loginLifecycle.ts"; +import type { SecurityAuditLifecycleService } from "../server/domains/security/securityAuditLifecycle.ts"; import type { ReadinessController } from "../server/platform/readiness/readinessState.ts"; import type { ApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; import { readRuntimeIdentity } from "../server/platform/runtime/readRuntimeIdentity.ts"; @@ -134,6 +135,7 @@ export interface ServerOptions { readonly mfaLoginLifecycle: MfaLoginLifecycleService; readonly port: number; readonly readiness: ReadinessController; + readonly securityAuditLifecycle: SecurityAuditLifecycleService; /** Exact proxy peers allowed to supply one overwritten client address. */ readonly trustedProxyAddresses?: readonly string[]; } @@ -170,6 +172,7 @@ export async function createServer(options: ServerOptions): Promise { rejectsBatch: true, requestBodyMaximumBytes: authenticationRequestBodyMaximumBytes, }); + expect(policy("/trpc/auth.loginWebAuthn")).toEqual({ + handlerIdleTimeoutSeconds: authenticationHandlerIdleTimeoutSeconds, + rejectsBatch: false, + requestBodyMaximumBytes: webAuthnRequestBodyMaximumBytes, + }); + expect(policy("/trpc/accountSecurity.stepUpWebAuthn")).toEqual({ + handlerIdleTimeoutSeconds: authenticationHandlerIdleTimeoutSeconds, + rejectsBatch: false, + requestBodyMaximumBytes: webAuthnRequestBodyMaximumBytes, + }); + expect(policy("/trpc/accountSecurity.confirmWebAuthnEnrollment")).toEqual({ + handlerIdleTimeoutSeconds: authenticationHandlerIdleTimeoutSeconds, + rejectsBatch: false, + requestBodyMaximumBytes: webAuthnRequestBodyMaximumBytes, + }); expect(policy("/trpc/events.stream")).toEqual({ handlerIdleTimeoutSeconds: 0, rejectsBatch: false, @@ -56,6 +84,38 @@ describe("tRPC request policy", () => { rejectsBatch: false, requestBodyMaximumBytes: authenticationRequestBodyMaximumBytes, }); + expect( + policy("/trpc/auth.status,accountSecurity.confirmWebAuthnEnrollment?batch=1") + ).toEqual({ + handlerIdleTimeoutSeconds: authenticationHandlerIdleTimeoutSeconds, + rejectsBatch: true, + requestBodyMaximumBytes: webAuthnRequestBodyMaximumBytes, + }); + }); + + test("fits the largest accepted WebAuthn enrollment inside its exact budget", () => { + const credentialId = "A".repeat(webAuthnCredentialIdMaximumLength); + const input = v.parse(confirmWebAuthnEnrollmentInputSchema, { + response: { + authenticatorAttachment: "cross-platform", + clientExtensionResults: { credProps: { rk: true } }, + id: credentialId, + rawId: credentialId, + response: { + attestationObject: "A".repeat(webAuthnAttestationObjectMaximumLength), + authenticatorData: "A".repeat(webAuthnAuthenticatorDataMaximumLength), + clientDataJSON: "A".repeat(webAuthnClientDataMaximumLength), + publicKey: "A".repeat(webAuthnPublicKeyMaximumLength * 2), + publicKeyAlgorithm: webAuthnSupportedAlgorithm, + transports: [...webAuthnTransports], + }, + type: "public-key", + }, + }); + const encodedBytes = Buffer.byteLength(JSON.stringify({ json: input })); + + expect(encodedBytes).toBeGreaterThan(authenticationRequestBodyMaximumBytes); + expect(encodedBytes).toBeLessThan(webAuthnRequestBodyMaximumBytes); }); test("fails closed for unknown names in registered authentication namespaces", () => { diff --git a/greenfield/src/app/trpcRequestPolicy.ts b/greenfield/src/app/trpcRequestPolicy.ts index 4989b85de..52fce5011 100644 --- a/greenfield/src/app/trpcRequestPolicy.ts +++ b/greenfield/src/app/trpcRequestPolicy.ts @@ -9,6 +9,8 @@ export const serverRequestBodyMaximumBytes = 64 * 1024; export const trpcRequestBodyMaximumBytes = serverRequestBodyMaximumBytes; /** Raw body ceiling for authentication and account-security procedures. */ export const authenticationRequestBodyMaximumBytes = 16 * 1024; +/** Raw body ceiling for bounded WebAuthn authentication responses. */ +export const webAuthnRequestBodyMaximumBytes = 32 * 1024; /** Maximum procedure count accepted by the tRPC adapter in one request. */ export const trpcMaximumBatchSize = 8; /** Idle budget for bounded authentication cryptography and upstream verification. */ @@ -39,7 +41,7 @@ function buildProcedureContractIndex(contracts: readonly ProcedureContract[]): { contractsByName.set(contract.name, contract); if ( contract.transport.handler === "authentication" || - contract.transport.requestBody === "authentication" + contract.transport.requestBody !== "default" ) { const namespace = procedureNamespace(contract.name); if (namespace === undefined) { @@ -56,7 +58,7 @@ function buildProcedureContractIndex(contracts: readonly ProcedureContract[]): { namespace !== undefined && authenticationNamespaces.has(namespace) && (contract.transport.handler !== "authentication" || - contract.transport.requestBody !== "authentication") + contract.transport.requestBody === "default") ) { throw new Error( `Authentication procedure namespace has inconsistent transport policy: ${namespace}` @@ -90,6 +92,7 @@ function effectivePolicy(input: { readonly containsAuthenticationProcedure: boolean; readonly containsForbiddenBatchProcedure: boolean; readonly containsLongLivedProcedure: boolean; + readonly containsWebAuthnProcedure: boolean; readonly isBatchRequest: boolean; }): TrpcRequestPolicy { let handlerIdleTimeoutSeconds: number | undefined; @@ -100,12 +103,17 @@ function effectivePolicy(input: { } const handlerPolicy = handlerIdleTimeoutSeconds === undefined ? {} : { handlerIdleTimeoutSeconds }; + let requestBodyMaximumBytes = trpcRequestBodyMaximumBytes; + if (input.containsAuthenticationProcedure) { + requestBodyMaximumBytes = authenticationRequestBodyMaximumBytes; + } + if (input.containsWebAuthnProcedure) { + requestBodyMaximumBytes = webAuthnRequestBodyMaximumBytes; + } return Object.freeze({ ...handlerPolicy, rejectsBatch: input.isBatchRequest && input.containsForbiddenBatchProcedure, - requestBodyMaximumBytes: input.containsAuthenticationProcedure - ? authenticationRequestBodyMaximumBytes - : trpcRequestBodyMaximumBytes, + requestBodyMaximumBytes, }); } @@ -134,6 +142,7 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { let containsAuthenticationProcedure = false; let containsForbiddenBatchProcedure = false; let containsLongLivedProcedure = false; + let containsWebAuthnProcedure = false; for (const procedure of procedures) { const contract = contractsByName.get(procedure); if (contract !== undefined) { @@ -143,6 +152,8 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { contract.transport.batching === "forbidden"; containsLongLivedProcedure ||= contract.transport.handler === "long-lived"; + containsWebAuthnProcedure ||= + contract.transport.requestBody === "webauthn"; continue; } const namespace = procedureNamespace(procedure); @@ -155,6 +166,7 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { containsAuthenticationProcedure, containsForbiddenBatchProcedure, containsLongLivedProcedure, + containsWebAuthnProcedure, isBatchRequest, }); } catch { @@ -164,6 +176,7 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { containsAuthenticationProcedure, containsForbiddenBatchProcedure: containsAuthenticationProcedure, containsLongLivedProcedure: false, + containsWebAuthnProcedure: false, isBatchRequest, }); } diff --git a/greenfield/src/browser/queryClient.ts b/greenfield/src/browser/api/queryClient.ts similarity index 100% rename from greenfield/src/browser/queryClient.ts rename to greenfield/src/browser/api/queryClient.ts diff --git a/greenfield/src/browser/api/trpcClient.test.ts b/greenfield/src/browser/api/trpcClient.test.ts new file mode 100644 index 000000000..f6eb70207 --- /dev/null +++ b/greenfield/src/browser/api/trpcClient.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +import { + createDashboardTrpcClient, + DashboardProtocolError, + type DashboardTrpcTransport, +} from "./trpcClient.ts"; + +interface TransportCall { + readonly input: unknown; + readonly kind: "mutation" | "query"; + readonly path: string; +} + +function createRecordingTransport( + output: unknown, + calls: TransportCall[] +): DashboardTrpcTransport { + return { + mutation(path, input) { + calls.push({ input, kind: "mutation", path }); + return Promise.resolve(output); + }, + query(path, input) { + calls.push({ input, kind: "query", path }); + return Promise.resolve(output); + }, + }; +} + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error; + } + throw new TypeError("Expected promise to reject"); +} + +describe("Dashboard browser tRPC client", () => { + test("validates an exact registered query at both contract boundaries", async () => { + const calls: TransportCall[] = []; + const client = createDashboardTrpcClient( + createRecordingTransport({ state: "anonymous" }, calls) + ); + + expect(await client.query("auth.status", {})).toEqual({ + state: "anonymous", + }); + expect(calls).toEqual([{ input: {}, kind: "query", path: "auth.status" }]); + }); + + test("sends security mutations individually without a batch path", async () => { + const calls: TransportCall[] = []; + const client = createDashboardTrpcClient( + createRecordingTransport({ isOk: true }, calls) + ); + + expect(await client.mutation("auth.logout", {})).toEqual({ + isOk: true, + }); + expect(calls).toEqual([{ input: {}, kind: "mutation", path: "auth.logout" }]); + }); + + test("rejects invalid input before transport access", async () => { + const calls: TransportCall[] = []; + const client = createDashboardTrpcClient( + createRecordingTransport({ status: "authenticated" }, calls) + ); + + expect( + await rejectionOf( + client.mutation("auth.login", { + password: "short", + username: "x", + }) + ) + ).toBeInstanceOf(DashboardProtocolError); + expect(calls).toEqual([]); + }); + + test("redacts a response contract violation", async () => { + const privateSentinel = "private-response-sentinel"; + const client = createDashboardTrpcClient( + createRecordingTransport({ privateSentinel, state: "not-a-real-state" }, []) + ); + + const rejection = await rejectionOf(client.query("auth.status", {})); + expect(rejection).toBeInstanceOf(DashboardProtocolError); + expect(String(rejection)).not.toContain(privateSentinel); + }); +}); diff --git a/greenfield/src/browser/api/trpcClient.ts b/greenfield/src/browser/api/trpcClient.ts new file mode 100644 index 000000000..0ec79722f --- /dev/null +++ b/greenfield/src/browser/api/trpcClient.ts @@ -0,0 +1,173 @@ +import { createTRPCUntypedClient, httpLink, type TRPCRequestOptions } from "@trpc/client"; +import superjson from "superjson"; +import * as v from "valibot"; + +import type { RegisteredProcedureContract } from "../../contracts/contractRegistry.ts"; +import type { ProcedureContract } from "../../contracts/registry.ts"; + +type ProcedureOfKind = Extract< + RegisteredProcedureContract, + { readonly kind: TKind } +>; + +/** Exact registered query name exposed to browser callers. */ +export type DashboardQueryName = ProcedureOfKind<"query">["name"]; + +/** Exact registered mutation name exposed to browser callers. */ +export type DashboardMutationName = ProcedureOfKind<"mutation">["name"]; + +type DashboardProcedureName = DashboardMutationName | DashboardQueryName; +type ContractForName = Extract< + RegisteredProcedureContract, + { readonly name: TName } +>; + +/** Validated input for one exact registered query or mutation. */ +export type DashboardProcedureInput = v.InferOutput< + ContractForName["input"] +>; + +/** Validated output for one exact registered query or mutation. */ +export type DashboardProcedureOutput = + v.InferOutput["output"]>; + +/** Minimal transport authority retained behind the browser contract boundary. */ +export interface DashboardTrpcTransport { + mutation( + path: string, + input?: unknown, + options?: TRPCRequestOptions + ): Promise; + query(path: string, input?: unknown, options?: TRPCRequestOptions): Promise; +} + +/** Browser-safe protocol failure without response or validation details. */ +export class DashboardProtocolError extends Error { + constructor() { + super("Dashboard response did not match its contract"); + this.name = "DashboardProtocolError"; + } +} + +/** Contract-validating browser client for registered non-streaming procedures. */ +export interface DashboardTrpcClient { + mutation( + name: TName, + input: DashboardProcedureInput, + options?: TRPCRequestOptions + ): Promise>; + query( + name: TName, + input: DashboardProcedureInput, + options?: TRPCRequestOptions + ): Promise>; +} + +async function procedureContractsFor( + name: DashboardProcedureName +): Promise { + const domain = name.slice(0, name.indexOf(".")); + switch (domain) { + case "accountSecurity": { + const module = await import("../../contracts/accountSecurity.ts"); + return module.accountSecurityProcedureContracts; + } + case "auth": { + const module = await import("../../contracts/auth.ts"); + return module.authProcedureContracts; + } + case "automationSecurity": { + const module = await import("../../contracts/automationSecurity.ts"); + return module.automationSecurityProcedureContracts; + } + case "securityAudit": { + const module = await import("../../contracts/securityAudit.ts"); + return module.securityAuditProcedureContracts; + } + case "system": { + const module = await import("../../contracts/system.ts"); + return module.systemProcedureContracts; + } + default: { + throw new DashboardProtocolError(); + } + } +} + +async function contractFor( + name: DashboardProcedureName, + kind: "mutation" | "query" +): Promise { + const contracts = await procedureContractsFor(name); + const contract = contracts.find((candidate) => candidate.name === name); + if (contract === undefined || contract.kind !== kind) { + throw new DashboardProtocolError(); + } + return contract; +} + +function parseContractValue(schema: v.GenericSchema, value: unknown): TValue { + const result = v.safeParse(schema, value); + if (!result.success) throw new DashboardProtocolError(); + return result.output as TValue; +} + +/** + * Creates the same-origin, non-batching tRPC transport used by the browser. + * @param url Same-origin tRPC mount. + * @returns One untyped transport kept behind validated procedure contracts. + */ +export function createDashboardTrpcTransport(url = "/trpc"): DashboardTrpcTransport { + return createTRPCUntypedClient({ + links: [ + httpLink({ + fetch(input, init) { + return globalThis.fetch(input, { + ...init, + cache: "no-store", + credentials: "same-origin", + }); + }, + transformer: superjson, + url, + }), + ], + }); +} + +/** + * Creates a browser client that validates both request and response values against + * the same Valibot contracts used by the server and generated documentation. + * @param transport Injected tRPC transport for the application or an isolated test. + * @returns Typed query and mutation operations. + */ +export function createDashboardTrpcClient( + transport: DashboardTrpcTransport = createDashboardTrpcTransport() +): DashboardTrpcClient { + return { + async mutation(name, input, options) { + const contract = await contractFor(name, "mutation"); + const parsedInput = parseContractValue>( + contract.input, + input + ); + const output = await transport.mutation(name, parsedInput, options); + return parseContractValue>( + contract.output, + output + ); + }, + async query(name, input, options) { + const contract = await contractFor(name, "query"); + const parsedInput = parseContractValue>( + contract.input, + input + ); + const output = await transport.query(name, parsedInput, options); + return parseContractValue>( + contract.output, + output + ); + }, + }; +} diff --git a/greenfield/src/browser/api/trpcContext.tsx b/greenfield/src/browser/api/trpcContext.tsx new file mode 100644 index 000000000..6c57f6c21 --- /dev/null +++ b/greenfield/src/browser/api/trpcContext.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +import type { DashboardTrpcClient } from "./trpcClient.ts"; +import { dashboardTrpcContext as DashboardTrpcContext } from "./trpcContextValue.ts"; + +/** Browser tRPC provider dependencies. */ +export interface DashboardTrpcProviderProps { + readonly children: ReactNode; + readonly client: DashboardTrpcClient; +} + +/** + * Provides the single browser-owned contract client to route components. + * @returns The provider boundary. + */ +export function DashboardTrpcProvider({ children, client }: DashboardTrpcProviderProps) { + return {children}; +} diff --git a/greenfield/src/browser/api/trpcContextValue.ts b/greenfield/src/browser/api/trpcContextValue.ts new file mode 100644 index 000000000..44c44a7a9 --- /dev/null +++ b/greenfield/src/browser/api/trpcContextValue.ts @@ -0,0 +1,20 @@ +import { createContext, use } from "react"; + +import type { DashboardTrpcClient } from "./trpcClient.ts"; + +/** Internal context shared by the browser provider and typed consumer hook. */ +export const dashboardTrpcContext = createContext( + undefined +); + +/** + * Reads the browser-owned contract client. + * @returns The configured client. + */ +export function useDashboardTrpcClient(): DashboardTrpcClient { + const client = use(dashboardTrpcContext); + if (client === undefined) { + throw new TypeError("Dashboard tRPC provider is missing"); + } + return client; +} diff --git a/greenfield/src/browser/api/trpcError.ts b/greenfield/src/browser/api/trpcError.ts new file mode 100644 index 000000000..589813117 --- /dev/null +++ b/greenfield/src/browser/api/trpcError.ts @@ -0,0 +1,124 @@ +import * as v from "valibot"; + +import { + contractAuthenticationErrorReasons, + contractErrorCodes, +} from "../../contracts/registry.ts"; +import { DashboardProtocolError } from "./trpcClient.ts"; + +const clientErrorCodeSchema = v.picklist([ + ...contractErrorCodes, + "CLIENT_CLOSED_REQUEST", + "INTERNAL_SERVER_ERROR", + "METHOD_NOT_SUPPORTED", + "PARSE_ERROR", + "TIMEOUT", +]); + +const clientErrorSchema = v.object({ + data: v.nullish( + v.object({ + code: v.optional(clientErrorCodeSchema), + reason: v.optional(v.picklist(contractAuthenticationErrorReasons)), + }) + ), +}); + +/** Fixed browser-facing failure categories; no server message is rendered. */ +export type DashboardBrowserFailure = + | "cancelled" + | "conflict" + | "forbidden" + | "invalid-request" + | "mfa-enrollment-required" + | "not-found" + | "protocol" + | "rate-limited" + | "step-up-required" + | "unauthorized" + | "unavailable" + | "unknown"; + +function isCancelledBrowserCeremony(error: unknown): boolean { + return ( + error instanceof DOMException && + (error.name === "AbortError" || error.name === "NotAllowedError") + ); +} + +/** + * Classifies an untrusted transport or browser-ceremony failure without retaining + * its message, response body, path, stack, or cause. + * @param error Unknown rejection. + * @returns One fixed browser failure category. + */ +export function classifyDashboardBrowserFailure(error: unknown): DashboardBrowserFailure { + if (error instanceof DashboardProtocolError) return "protocol"; + if (isCancelledBrowserCeremony(error)) return "cancelled"; + + const parsed = v.safeParse(clientErrorSchema, error); + if (!parsed.success) return "unknown"; + const reason = parsed.output.data?.reason; + if (reason === "mfa_enrollment_required") return "mfa-enrollment-required"; + if (reason === "step_up_required") return "step-up-required"; + + switch (parsed.output.data?.code) { + case "BAD_REQUEST": + case "PARSE_ERROR": { + return "invalid-request"; + } + case "CLIENT_CLOSED_REQUEST": { + return "cancelled"; + } + case "CONFLICT": + case "PRECONDITION_FAILED": { + return "conflict"; + } + case "FORBIDDEN": { + return "forbidden"; + } + case "NOT_FOUND": { + return "not-found"; + } + case "TOO_MANY_REQUESTS": { + return "rate-limited"; + } + case "UNAUTHORIZED": { + return "unauthorized"; + } + case "INTERNAL_SERVER_ERROR": + case "METHOD_NOT_SUPPORTED": + case "SERVICE_UNAVAILABLE": + case "TIMEOUT": { + return "unavailable"; + } + case undefined: { + return "unknown"; + } + } +} + +const browserFailureMessages: Readonly> = { + cancelled: "The security prompt was cancelled. You can try again.", + conflict: "The security state changed. Refresh the page and try again.", + forbidden: "This session is not permitted to perform that action.", + "invalid-request": "Check the entered values and try again.", + "mfa-enrollment-required": + "Multi-factor authentication must be enrolled before this action.", + "not-found": "The selected security record no longer exists.", + protocol: "The server returned an invalid response. Reload before trying again.", + "rate-limited": "Too many attempts were made. Wait before trying again.", + "step-up-required": "Verify your identity again before continuing.", + unauthorized: "The credentials or session are no longer valid.", + unavailable: "The Dashboard is temporarily unavailable. Try again shortly.", + unknown: "The request could not be completed. Try again.", +}; + +/** + * Formats one fixed, non-sensitive browser message. + * @param error Unknown rejection. + * @returns Safe text suitable for an alert. + */ +export function dashboardBrowserFailureMessage(error: unknown): string { + return browserFailureMessages[classifyDashboardBrowserFailure(error)]; +} diff --git a/greenfield/src/browser/application.test.tsx b/greenfield/src/browser/application.test.tsx index b57c857b2..1dab86e7d 100644 --- a/greenfield/src/browser/application.test.tsx +++ b/greenfield/src/browser/application.test.tsx @@ -1,33 +1,67 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createMemoryHistory } from "@tanstack/react-router"; +import { createDashboardQueryClient } from "./api/queryClient.ts"; +import { createDashboardTrpcClient } from "./api/trpcClient.ts"; import { DashboardBrowserApplication } from "./application.tsx"; -import { createDashboardQueryClient } from "./queryClient.ts"; +import { authStatusQueryKey } from "./auth/authQueries.ts"; import { createDashboardRouter } from "./router.tsx"; -import { acquireBrowserTestEnvironment } from "./testSupport/browserTestEnvironment.ts"; +import type { DashboardWebAuthnClient } from "./security/webauthn/webauthnClient.ts"; -const browserEnvironment = await acquireBrowserTestEnvironment(); -const { cleanup, render, screen } = await import("@testing-library/react"); - -afterEach(() => { - cleanup(); -}); - -afterAll(async () => { - await browserEnvironment.release(); +const { render, screen, waitFor } = await import("@testing-library/react"); +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), }); describe("Dashboard browser application", () => { - test("renders the accessible overview through the real providers and router", async () => { + test("renders the overview and owns throttled authenticated activity", async () => { + const timestampMs = Date.now(); const queryClient = createDashboardQueryClient(); const router = createDashboardRouter( createMemoryHistory({ initialEntries: ["/"] }) ); + let touchCalls = 0; + const trpcClient = createDashboardTrpcClient({ + mutation(path, input) { + expect(path).toBe("auth.touch"); + expect(input).toEqual({}); + touchCalls += 1; + return Promise.resolve({ lastSeenAtMs: timestampMs }); + }, + query(path) { + if (path !== "auth.status") { + return Promise.reject(new TypeError("Unexpected query")); + } + return Promise.resolve({ + session: { + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs - 61_000, + userAgent: "Dashboard browser test", + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, + }); + }, + }); try { render( - + ); const heading = await screen.findByRole("heading", { @@ -41,7 +75,13 @@ describe("Dashboard browser application", () => { expect( screen.getByRole("status", { name: "Application status" }).textContent ).toContain("Application shell ready"); - expect(queryClient.getQueryCache().getAll()).toEqual([]); + expect(queryClient.getQueryCache().getAll()).toHaveLength(1); + await waitFor(() => expect(touchCalls).toBe(1)); + expect(queryClient.getQueryData(authStatusQueryKey)).toMatchObject({ + session: { lastSeenAtMs: timestampMs }, + state: "authenticated", + user: { username: "operator" }, + }); } finally { queryClient.clear(); } diff --git a/greenfield/src/browser/application.tsx b/greenfield/src/browser/application.tsx index 89db717b2..cf79fd644 100644 --- a/greenfield/src/browser/application.tsx +++ b/greenfield/src/browser/application.tsx @@ -1,59 +1,68 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { ErrorBoundary } from "react-error-boundary"; -import type { DashboardRouter } from "./router.tsx"; +import { createDashboardQueryClient } from "./api/queryClient.ts"; +import { createDashboardTrpcClient, type DashboardTrpcClient } from "./api/trpcClient.ts"; +import { DashboardTrpcProvider } from "./api/trpcContext.tsx"; +import { AuthenticatedSessionActivity } from "./auth/AuthenticatedSessionActivity.tsx"; +import { createDashboardRouter, type DashboardRouter } from "./router.tsx"; +import { + createDashboardWebAuthnClient, + type DashboardWebAuthnClient, +} from "./security/webauthn/webauthnClient.ts"; +import { DashboardWebAuthnProvider } from "./security/webauthn/webauthnContext.tsx"; +import { AppErrorFallback } from "./ui/AppErrorFallback.tsx"; -function DashboardErrorFallback({ resetErrorBoundary }: FallbackProps) { - return ( -
-
-

Application error

-

- Dashboard unavailable -

-

- The browser application could not finish rendering. No private error - details were displayed. -

- -
-
- ); -} +const queryClient = createDashboardQueryClient(); +const router = createDashboardRouter(); +const trpcClient = createDashboardTrpcClient(); +const webAuthnClient = createDashboardWebAuthnClient(); -/** Browser application dependencies constructed once by `main.tsx`. */ +/** Browser dependencies accepted by the testable provider boundary. */ export interface DashboardBrowserApplicationProps { readonly queryClient: QueryClient; readonly router: DashboardRouter; + readonly trpcClient: DashboardTrpcClient; + readonly webAuthnClient: DashboardWebAuthnClient; } /** * Renders the root error, query, and routing boundaries. - * @returns The composed browser application. + * @param props Browser dependencies owned by the composition root. + * @returns The composed browser provider graph. */ export function DashboardBrowserApplication({ queryClient, router, + trpcClient, + webAuthnClient, }: DashboardBrowserApplicationProps) { return ( - + - + + + + + + ); } + +/** + * Owns browser services for the complete document lifetime. + * @returns The Dashboard application composed with its production dependencies. + */ +export default function DashboardBrowserApplicationRoot() { + return ( + + ); +} diff --git a/greenfield/src/browser/auth/AuthenticatedSessionActivity.test.tsx b/greenfield/src/browser/auth/AuthenticatedSessionActivity.test.tsx new file mode 100644 index 000000000..f6b00c58a --- /dev/null +++ b/greenfield/src/browser/auth/AuthenticatedSessionActivity.test.tsx @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; + +import { QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { createDashboardTrpcClient } from "../api/trpcClient.ts"; +import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; +import { AuthenticatedSessionActivity } from "./AuthenticatedSessionActivity.tsx"; +import { authStatusQueryKey } from "./authQueries.ts"; + +const { render, waitFor } = await import("@testing-library/react"); + +function authenticatedStatus(lastSeenAtMs: number): AuthStatus { + return { + session: { + authenticatedAtMs: lastSeenAtMs, + authMethod: "password", + createdAtMs: lastSeenAtMs, + expiresAtMs: lastSeenAtMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs, + userAgent: "Dashboard browser test", + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, + }; +} + +describe("authenticated browser activity", () => { + test("touches stale activity once, refreshes the cache, and removes listeners", async () => { + const queryClient = createDashboardQueryClient(); + const staleLastSeenAtMs = Date.now() - 61_000; + const touchedAtMs = staleLastSeenAtMs + 60_000; + const staleStatus = authenticatedStatus(staleLastSeenAtMs); + const statusRefresh = Promise.withResolvers(); + queryClient.setQueryData(authStatusQueryKey, staleStatus); + let mutationCalls = 0; + const client = createDashboardTrpcClient({ + mutation(path, input) { + expect(path).toBe("auth.touch"); + expect(input).toEqual({}); + mutationCalls += 1; + return Promise.resolve({ lastSeenAtMs: touchedAtMs }); + }, + query(path) { + expect(path).toBe("auth.status"); + return statusRefresh.promise; + }, + }); + const rendered = render( + + + + + + ); + let unmounted = false; + + try { + act(() => { + document.dispatchEvent(new Event("pointerdown")); + }); + await waitFor(() => expect(mutationCalls).toBe(1)); + expect( + queryClient.getQueryData(authStatusQueryKey) + ).toMatchObject({ + session: { lastSeenAtMs: touchedAtMs }, + state: "authenticated", + }); + + act(() => { + document.dispatchEvent(new Event("keydown")); + document.dispatchEvent(new Event("scroll")); + }); + expect(mutationCalls).toBe(1); + + await act(async () => { + statusRefresh.resolve(authenticatedStatus(touchedAtMs)); + await statusRefresh.promise; + }); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + rendered.unmount(); + unmounted = true; + queryClient.setQueryData( + authStatusQueryKey, + authenticatedStatus(staleLastSeenAtMs) + ); + document.dispatchEvent(new Event("pointerdown")); + expect(mutationCalls).toBe(1); + } finally { + statusRefresh.resolve(authenticatedStatus(touchedAtMs)); + if (!unmounted) rendered.unmount(); + queryClient.clear(); + } + }); + + test("captures non-bubbling scroll activity from an overflow container", async () => { + const queryClient = createDashboardQueryClient(); + const currentLastSeenAtMs = Date.now(); + const staleLastSeenAtMs = currentLastSeenAtMs - 61_000; + const touchedAtMs = currentLastSeenAtMs + 1; + queryClient.setQueryData( + authStatusQueryKey, + authenticatedStatus(currentLastSeenAtMs) + ); + let mutationCalls = 0; + const client = createDashboardTrpcClient({ + mutation(path) { + expect(path).toBe("auth.touch"); + mutationCalls += 1; + return Promise.resolve({ lastSeenAtMs: touchedAtMs }); + }, + query(path) { + expect(path).toBe("auth.status"); + return Promise.resolve(authenticatedStatus(currentLastSeenAtMs)); + }, + }); + const rendered = render( + + + + + + ); + const scrollContainer = document.createElement("div"); + document.body.append(scrollContainer); + + try { + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(mutationCalls).toBe(0); + queryClient.setQueryData( + authStatusQueryKey, + authenticatedStatus(staleLastSeenAtMs) + ); + + act(() => { + scrollContainer.dispatchEvent(new Event("scroll", { bubbles: false })); + }); + + await waitFor(() => expect(mutationCalls).toBe(1)); + } finally { + scrollContainer.remove(); + rendered.unmount(); + queryClient.clear(); + } + }); + + test("clears authenticated state when the server rejects a touch", async () => { + const queryClient = createDashboardQueryClient(); + const staleStatus = authenticatedStatus(Date.now() - 61_000); + const statusRefresh = Promise.withResolvers(); + const unauthorizedTouchError = Object.assign(new Error("Touch rejected"), { + data: { code: "UNAUTHORIZED" }, + }); + queryClient.setQueryData(authStatusQueryKey, staleStatus); + queryClient.setQueryData(["security", "private"], { private: true }); + const client = createDashboardTrpcClient({ + mutation(path) { + expect(path).toBe("auth.touch"); + return Promise.reject(unauthorizedTouchError); + }, + query(path) { + expect(path).toBe("auth.status"); + return statusRefresh.promise; + }, + }); + const rendered = render( + + + + + + ); + + try { + await waitFor(() => + expect(queryClient.getQueryData(authStatusQueryKey)).toEqual({ + state: "anonymous", + }) + ); + expect(queryClient.getQueryData(["security", "private"])).toBeUndefined(); + + statusRefresh.resolve(staleStatus); + await statusRefresh.promise; + expect(queryClient.getQueryData(authStatusQueryKey)).toEqual({ + state: "anonymous", + }); + } finally { + statusRefresh.resolve(staleStatus); + rendered.unmount(); + queryClient.clear(); + } + }); +}); diff --git a/greenfield/src/browser/auth/AuthenticatedSessionActivity.tsx b/greenfield/src/browser/auth/AuthenticatedSessionActivity.tsx new file mode 100644 index 000000000..dfeb952ca --- /dev/null +++ b/greenfield/src/browser/auth/AuthenticatedSessionActivity.tsx @@ -0,0 +1,151 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { differenceInMilliseconds, minutesToMilliseconds } from "date-fns"; +import { useEffect } from "react"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { classifyDashboardBrowserFailure } from "../api/trpcError.ts"; +import { + authStatusQueryKey, + authStatusQueryOptions, + resetAuthenticatedBrowserCache, +} from "./authQueries.ts"; + +const browserSessionTouchThrottleMs = minutesToMilliseconds(1); +const authenticatedActivityEvents = ["keydown", "pointerdown"] as const; +const passiveActivityListenerOptions = Object.freeze({ passive: true }); +const capturedPassiveActivityListenerOptions = Object.freeze({ + capture: true, + passive: true, +}); + +function activityTouchIsDue( + status: AuthStatus | undefined, + nowMs: number, + lastAttemptAtMs: number | undefined +): boolean { + if (status?.state !== "authenticated") return false; + if ( + differenceInMilliseconds(nowMs, status.session.lastSeenAtMs) < + browserSessionTouchThrottleMs + ) { + return false; + } + return ( + lastAttemptAtMs === undefined || + differenceInMilliseconds(nowMs, lastAttemptAtMs) >= browserSessionTouchThrottleMs + ); +} + +function updateCachedSessionActivity( + status: AuthStatus | undefined, + lastSeenAtMs: number +): AuthStatus | undefined { + if ( + status?.state !== "authenticated" || + status.session.lastSeenAtMs >= lastSeenAtMs + ) { + return status; + } + return { + ...status, + session: { ...status.session, lastSeenAtMs }, + }; +} + +/** + * Records explicit activity for an authenticated browser without write-amplifying + * high-frequency DOM events. + * @returns No visual output. + */ +export function AuthenticatedSessionActivity() { + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const status = useQuery(authStatusQueryOptions(client)); + const authenticatedSessionId = + status.data?.state === "authenticated" ? status.data.session.id : undefined; + + useEffect(() => { + if (authenticatedSessionId === undefined) return; + let active = true; + let inFlight = false; + let lastAttemptAtMs: number | undefined; + let requestController: AbortController | undefined; + + async function touchSession(controller: AbortController): Promise { + try { + const result = await client.mutation( + "auth.touch", + {}, + { signal: controller.signal } + ); + if (!active) return; + queryClient.setQueryData(authStatusQueryKey, (status) => + updateCachedSessionActivity(status, result.lastSeenAtMs) + ); + } catch (error: unknown) { + if ( + active && + !controller.signal.aborted && + classifyDashboardBrowserFailure(error) === "unauthorized" + ) { + await queryClient.cancelQueries(); + if (active) { + resetAuthenticatedBrowserCache(queryClient, { + state: "anonymous", + }); + } + } + } finally { + inFlight = false; + if (requestController === controller) requestController = undefined; + } + } + + function requestTouch(): void { + if (inFlight) return; + const nowMs = Date.now(); + const status = queryClient.getQueryData(authStatusQueryKey); + if (!activityTouchIsDue(status, nowMs, lastAttemptAtMs)) return; + + lastAttemptAtMs = nowMs; + inFlight = true; + const controller = new AbortController(); + requestController = controller; + void touchSession(controller); + } + + function requestTouchWhenVisible(): void { + if (document.visibilityState === "visible") requestTouch(); + } + + requestTouchWhenVisible(); + for (const eventName of authenticatedActivityEvents) { + document.addEventListener( + eventName, + requestTouch, + passiveActivityListenerOptions + ); + } + document.addEventListener( + "scroll", + requestTouch, + capturedPassiveActivityListenerOptions + ); + document.addEventListener("visibilitychange", requestTouchWhenVisible); + window.addEventListener("focus", requestTouch); + + return () => { + active = false; + requestController?.abort(); + for (const eventName of authenticatedActivityEvents) { + document.removeEventListener(eventName, requestTouch); + } + document.removeEventListener("scroll", requestTouch, true); + document.removeEventListener("visibilitychange", requestTouchWhenVisible); + window.removeEventListener("focus", requestTouch); + }; + }, [authenticatedSessionId, client, queryClient]); + + return null; +} diff --git a/greenfield/src/browser/auth/AuthenticationBoundary.test.tsx b/greenfield/src/browser/auth/AuthenticationBoundary.test.tsx new file mode 100644 index 000000000..dc9edde6d --- /dev/null +++ b/greenfield/src/browser/auth/AuthenticationBoundary.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; +import { act } from "react"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardBrowserApplication } from "../application.tsx"; +import { createDashboardRouter } from "../router.tsx"; +import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; +import { authStatusQueryKey } from "./authQueries.ts"; + +const { render, screen } = await import("@testing-library/react"); + +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), +}); + +class DeferredAuthenticationTransport implements DashboardTrpcTransport { + #statusRequest = Promise.withResolvers(); + #statusRequestPending = true; + #settledStatus: AuthStatus | undefined; + statusQueryCount = 0; + + mutation(path: string): Promise { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + + query(path: string): Promise { + if (path !== "auth.status") { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + this.statusQueryCount += 1; + if (this.#statusRequestPending) return this.#statusRequest.promise; + if (this.#settledStatus === undefined) { + return Promise.reject( + new TypeError("Authentication status was not resolved") + ); + } + return Promise.resolve(this.#settledStatus); + } + + deferStatus(): void { + this.#statusRequest = Promise.withResolvers(); + this.#statusRequestPending = true; + } + + resolveStatus(status: AuthStatus): void { + this.#settledStatus = status; + this.#statusRequestPending = false; + this.#statusRequest.resolve(status); + } +} + +describe("authenticated route boundary", () => { + test("hides protected content during fresh and background session checks", async () => { + const timestampMs = Date.now(); + const queryClient = createDashboardQueryClient(); + const transport = new DeferredAuthenticationTransport(); + const cachedAuthenticatedStatus = Object.freeze({ + session: { + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, + } satisfies AuthStatus); + queryClient.setQueryData(authStatusQueryKey, cachedAuthenticatedStatus); + + try { + render( + + ); + + expect(await screen.findByLabelText("Authentication status")).toBeTruthy(); + expect(transport.statusQueryCount).toBeGreaterThan(0); + expect( + screen.queryByRole("heading", { level: 1, name: "Mira Dashboard" }) + ).toBeNull(); + + await act(async () => { + transport.resolveStatus(cachedAuthenticatedStatus); + await Promise.resolve(); + }); + expect( + await screen.findByRole("heading", { + level: 1, + name: "Mira Dashboard", + }) + ).toBeTruthy(); + + transport.deferStatus(); + let backgroundCheck = Promise.resolve(); + act(() => { + backgroundCheck = queryClient.refetchQueries({ + exact: true, + queryKey: authStatusQueryKey, + }); + }); + expect(await screen.findByLabelText("Authentication status")).toBeTruthy(); + expect( + screen.queryByRole("heading", { level: 1, name: "Mira Dashboard" }) + ).toBeNull(); + + await act(async () => { + transport.resolveStatus({ state: "anonymous" }); + await backgroundCheck; + }); + expect( + await screen.findByRole("heading", { level: 1, name: "Sign in" }) + ).toBeTruthy(); + } finally { + queryClient.clear(); + } + }); +}); diff --git a/greenfield/src/browser/auth/AuthenticationBoundary.tsx b/greenfield/src/browser/auth/AuthenticationBoundary.tsx new file mode 100644 index 000000000..6f3bc8c58 --- /dev/null +++ b/greenfield/src/browser/auth/AuthenticationBoundary.tsx @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query"; +import { Navigate } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { Button } from "../ui/Button.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Text } from "../ui/Text.tsx"; +import { authStatusQueryOptions } from "./authQueries.ts"; + +/** Authenticated route boundary dependencies. */ +export interface AuthenticationBoundaryProps { + readonly children: ReactNode; +} + +/** + * Gates protected browser routes on current server-authenticated session state. + * The application root separately owns throttled explicit session activity writes. + * @returns Protected content, a bounded loading/error state, or a login redirect. + */ +export function AuthenticationBoundary({ children }: AuthenticationBoundaryProps) { + const client = useDashboardTrpcClient(); + const status = useQuery({ + ...authStatusQueryOptions(client), + refetchOnMount: "always", + }); + + if (!status.isFetchedAfterMount || status.fetchStatus !== "idle") { + return ( + + Checking your session… + + ); + } + if (status.isError) { + return ( + + + Session check failed + + + {dashboardBrowserFailureMessage(status.error)} + + + + ); + } + if (status.data?.state !== "authenticated") { + return ; + } + return children; +} diff --git a/greenfield/src/browser/auth/BootstrapForm.tsx b/greenfield/src/browser/auth/BootstrapForm.tsx new file mode 100644 index 000000000..ff7ab8bf4 --- /dev/null +++ b/greenfield/src/browser/auth/BootstrapForm.tsx @@ -0,0 +1,135 @@ +import { useForm } from "@tanstack/react-form"; +import { UserRoundPlus } from "lucide-react"; + +import { firstUserBootstrapInputSchema } from "../../contracts/auth.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { LoginPanel } from "./LoginPanel.tsx"; +import { useAuthenticationAction } from "./useAuthenticationAction.ts"; + +/** + * Verifies the existing Gateway credential and creates the first operator. + * @returns The one-time first-user setup form. + */ +export function BootstrapForm() { + const { busy, client, error, run } = useAuthenticationAction(); + const form = useForm({ + defaultValues: { + gatewayCredential: "", + password: "", + username: "", + }, + onSubmit: async ({ formApi, value }) => { + await run(async () => { + await client.mutation("auth.bootstrap", value); + formApi.setFieldValue("gatewayCredential", ""); + formApi.setFieldValue("password", ""); + }); + }, + validators: { onSubmit: firstUserBootstrapInputSchema }, + }); + + return ( + + +
void form.handleSubmit()}> +
+ + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + spellCheck={false} + value={field.state.value} + /> + + )} + + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + type="password" + value={field.state.value} + /> + + )} + + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + spellCheck={false} + type="password" + value={field.state.value} + /> + + )} + +
+ [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
+
+ ); +} diff --git a/greenfield/src/browser/auth/LoginPanel.tsx b/greenfield/src/browser/auth/LoginPanel.tsx new file mode 100644 index 000000000..ae4c3de5c --- /dev/null +++ b/greenfield/src/browser/auth/LoginPanel.tsx @@ -0,0 +1,39 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Text } from "../ui/Text.tsx"; + +interface LoginPanelProps { + readonly children: ReactNode; + readonly description: string; + readonly icon: LucideIcon; + readonly title: string; +} + +/** + * Renders the common branded frame for every authentication step. + * @returns A focused authentication card. + */ +export function LoginPanel({ children, description, icon, title }: LoginPanelProps) { + return ( + +
+ + Mira Dashboard +
+ + {title} + + + {description} + +
{children}
+
+ ); +} diff --git a/greenfield/src/browser/auth/LoginRoute.test.tsx b/greenfield/src/browser/auth/LoginRoute.test.tsx new file mode 100644 index 000000000..0b5c4d3c3 --- /dev/null +++ b/greenfield/src/browser/auth/LoginRoute.test.tsx @@ -0,0 +1,369 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; + +import type { AuthStatus, PendingLoginSummary } from "../../contracts/auth.ts"; +import type { + WebAuthnAuthenticationOptions, + WebAuthnAuthenticationResponse, +} from "../../contracts/webauthn.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardBrowserApplication } from "../application.tsx"; +import { createDashboardRouter } from "../router.tsx"; +import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; + +const { render, screen, waitFor } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const timestampMs = Date.now(); +const user = Object.freeze({ + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", +}); +const session = Object.freeze({ + authenticatedAtMs: timestampMs, + authMethod: "password" as const, + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + userAgent: "Dashboard browser test", +}); +const authenticatedStatus: AuthStatus = Object.freeze({ + session, + state: "authenticated", + user, +}); +const pendingLogin: PendingLoginSummary = Object.freeze({ + expiresAtMs: timestampMs + 60_000, + methods: ["totp", "recovery", "webauthn"] satisfies PendingLoginSummary["methods"], + username: user.username, +}); +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), +}); + +interface TransportCall { + readonly input: unknown; + readonly kind: "mutation" | "query"; + readonly path: string; +} + +class AuthenticationTransport implements DashboardTrpcTransport { + readonly calls: TransportCall[] = []; + mutationHandler: (path: string, input: unknown) => Promise = (path) => + Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + status: AuthStatus; + + constructor(status: AuthStatus) { + this.status = status; + } + + mutation(path: string, input?: unknown): Promise { + this.calls.push({ input, kind: "mutation", path }); + return this.mutationHandler(path, input); + } + + query(path: string, input?: unknown): Promise { + this.calls.push({ input, kind: "query", path }); + if (path !== "auth.status") { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + return Promise.resolve(this.status); + } +} + +const queryClients: ReturnType[] = []; + +function renderAuthenticationRoute( + transport: AuthenticationTransport, + options: { + readonly initialEntry?: "/" | "/login"; + readonly webAuthnClient?: DashboardWebAuthnClient; + } = {} +) { + const queryClient = createDashboardQueryClient(); + queryClients.push(queryClient); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: [options.initialEntry ?? "/login"] }) + ); + render( + + ); + return { queryClient, router }; +} + +function cachedBrowserData(queryClient: ReturnType) { + return JSON.stringify( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.state.data) + ); +} + +afterEach(() => { + for (const queryClient of queryClients.splice(0)) queryClient.clear(); +}); + +describe("Dashboard login route", () => { + test("redirects an anonymous protected route to password sign-in", async () => { + const transport = new AuthenticationTransport({ state: "anonymous" }); + renderAuthenticationRoute(transport, { initialEntry: "/" }); + + expect( + await screen.findByRole("heading", { level: 1, name: "Sign in" }) + ).toBeTruthy(); + expect(transport.calls.length).toBeGreaterThanOrEqual(2); + for (const call of transport.calls) { + expect(call).toEqual({ input: {}, kind: "query", path: "auth.status" }); + } + }); + + test("submits bootstrap secrets ephemerally and enters the authenticated route", async () => { + const transport = new AuthenticationTransport({ + state: "bootstrap-required", + }); + const password = "correct horse battery staple"; + const gatewayCredential = "gateway-bootstrap-credential"; + transport.mutationHandler = (path, input) => { + expect(path).toBe("auth.bootstrap"); + expect(input).toEqual({ gatewayCredential, password, username: "operator" }); + transport.status = authenticatedStatus; + return Promise.resolve({ session, user }); + }; + const { queryClient } = renderAuthenticationRoute(transport); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { + level: 1, + name: "Secure first-user setup", + }); + await userActions.type(screen.getByLabelText("Username"), "operator"); + await userActions.type(screen.getByLabelText("Dashboard password"), password); + await userActions.type( + screen.getByLabelText("Gateway credential"), + gatewayCredential + ); + await userActions.click(screen.getByRole("button", { name: "Create operator" })); + + expect( + await screen.findByRole("heading", { level: 1, name: "Mira Dashboard" }) + ).toBeTruthy(); + expect(cachedBrowserData(queryClient)).not.toContain(password); + expect(cachedBrowserData(queryClient)).not.toContain(gatewayCredential); + }); + + test("moves password login into the pending MFA state without caching the password", async () => { + const transport = new AuthenticationTransport({ state: "anonymous" }); + const password = "correct horse battery staple"; + transport.mutationHandler = (path, input) => { + expect(path).toBe("auth.login"); + expect(input).toEqual({ password, username: "operator" }); + transport.status = { pendingLogin, state: "pending-mfa" }; + return Promise.resolve({ pendingLogin, status: "mfa-required" }); + }; + const { queryClient } = renderAuthenticationRoute(transport); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { level: 1, name: "Sign in" }); + await userActions.type(screen.getByLabelText("Username"), "operator"); + await userActions.type(screen.getByLabelText("Password"), password); + await userActions.click(screen.getByRole("button", { name: "Continue" })); + + expect( + await screen.findByRole("heading", { + level: 1, + name: "Multi-factor authentication", + }) + ).toBeTruthy(); + expect(cachedBrowserData(queryClient)).not.toContain(password); + expect(screen.getByLabelText("Authenticator code")).toBeTruthy(); + expect(screen.getByLabelText("Recovery code")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Use a security key" })).toBeTruthy(); + }); + + test("keeps TOTP and recovery proofs independent and completes TOTP login", async () => { + const transport = new AuthenticationTransport({ + pendingLogin, + state: "pending-mfa", + }); + const code = "123456"; + transport.mutationHandler = (path, input) => { + expect(path).toBe("auth.loginTotp"); + expect(input).toEqual({ code }); + transport.status = authenticatedStatus; + return Promise.resolve({ session, user }); + }; + const { queryClient } = renderAuthenticationRoute(transport); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { + level: 1, + name: "Multi-factor authentication", + }); + await userActions.type(screen.getByLabelText("Authenticator code"), code); + expect(screen.getByLabelText("Recovery code").value).toBe(""); + await userActions.click(screen.getByRole("button", { name: "Verify code" })); + + await screen.findByRole("heading", { level: 1, name: "Mira Dashboard" }); + expect(cachedBrowserData(queryClient)).not.toContain(code); + }); + + test("resyncs pending MFA state when a failed proof clears its cookie", async () => { + const transport = new AuthenticationTransport({ + pendingLogin, + state: "pending-mfa", + }); + transport.mutationHandler = (path, input) => { + expect(path).toBe("auth.loginTotp"); + expect(input).toEqual({ code: "123456" }); + transport.status = { state: "anonymous" }; + return Promise.reject( + Object.assign(new Error("Pending login expired"), { + data: { code: "UNAUTHORIZED" }, + }) + ); + }; + renderAuthenticationRoute(transport); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { + level: 1, + name: "Multi-factor authentication", + }); + await userActions.type(screen.getByLabelText("Authenticator code"), "123456"); + await userActions.click(screen.getByRole("button", { name: "Verify code" })); + + expect( + await screen.findByRole("heading", { level: 1, name: "Sign in" }) + ).toBeTruthy(); + expect( + screen.queryByRole("heading", { + level: 1, + name: "Multi-factor authentication", + }) + ).toBeNull(); + }); + + test("completes recovery-code login without caching the proof", async () => { + const transport = new AuthenticationTransport({ + pendingLogin, + state: "pending-mfa", + }); + const code = `${"a".repeat(32)}-${"b".repeat(32)}`; + transport.mutationHandler = (path, input) => { + expect(path).toBe("auth.loginRecovery"); + expect(input).toEqual({ code }); + transport.status = authenticatedStatus; + return Promise.resolve({ session, user }); + }; + const { queryClient } = renderAuthenticationRoute(transport); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { + level: 1, + name: "Multi-factor authentication", + }); + await userActions.type(screen.getByLabelText("Recovery code"), code); + await userActions.click( + screen.getByRole("button", { name: "Use recovery code" }) + ); + + await screen.findByRole("heading", { level: 1, name: "Mira Dashboard" }); + expect(cachedBrowserData(queryClient)).not.toContain(code); + }); + + test("completes WebAuthn with the browser ceremony response wrapped by contract", async () => { + const transport = new AuthenticationTransport({ + pendingLogin, + state: "pending-mfa", + }); + const options: WebAuthnAuthenticationOptions = { + allowCredentials: [{ id: "AAAAAAAA", type: "public-key" }], + challenge: "A".repeat(32), + rpId: "localhost", + timeout: 60_000, + userVerification: "required", + }; + const response: WebAuthnAuthenticationResponse = { + authenticatorAttachment: "cross-platform", + clientExtensionResults: {}, + id: "AAAAAAAA", + rawId: "AAAAAAAA", + response: { + authenticatorData: "AAAA", + clientDataJSON: "AAAA", + signature: "AAAA", + }, + type: "public-key", + }; + const ceremonyInputs: WebAuthnAuthenticationOptions[] = []; + const webAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: (received: WebAuthnAuthenticationOptions) => { + ceremonyInputs.push(received); + return Promise.resolve(response); + }, + register: () => Promise.reject(new TypeError("Unexpected registration")), + }); + transport.mutationHandler = (path, input) => { + if (path === "auth.beginWebAuthnLogin") { + expect(input).toEqual({}); + return Promise.resolve({ expiresAtMs: timestampMs + 60_000, options }); + } + expect(path).toBe("auth.loginWebAuthn"); + expect(input).toEqual({ response }); + transport.status = authenticatedStatus; + return Promise.resolve({ session, user }); + }; + renderAuthenticationRoute(transport, { webAuthnClient }); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { + level: 1, + name: "Multi-factor authentication", + }); + await userActions.click( + screen.getByRole("button", { name: "Use a security key" }) + ); + + await screen.findByRole("heading", { level: 1, name: "Mira Dashboard" }); + expect(ceremonyInputs).toEqual([options]); + }); + + test("renders and focuses a fixed safe error without leaking transport details", async () => { + const transport = new AuthenticationTransport({ state: "anonymous" }); + const privateSentinel = "private-login-sentinel"; + const privateError = Object.assign(new Error(privateSentinel), { + data: { code: "TOO_MANY_REQUESTS" }, + }); + transport.mutationHandler = () => Promise.reject(privateError); + renderAuthenticationRoute(transport); + const userActions = userEvent.setup(); + + await screen.findByRole("heading", { level: 1, name: "Sign in" }); + await userActions.type(screen.getByLabelText("Username"), "operator"); + await userActions.type( + screen.getByLabelText("Password"), + "correct horse battery staple" + ); + await userActions.click(screen.getByRole("button", { name: "Continue" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Too many attempts"); + expect(alert.textContent).not.toContain(privateSentinel); + await waitFor(() => expect(document.activeElement).toBe(alert)); + }); +}); diff --git a/greenfield/src/browser/auth/LoginRoute.tsx b/greenfield/src/browser/auth/LoginRoute.tsx new file mode 100644 index 000000000..a5b347380 --- /dev/null +++ b/greenfield/src/browser/auth/LoginRoute.tsx @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; +import { Navigate } from "@tanstack/react-router"; + +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { PageState } from "../ui/PageState.tsx"; +import { authStatusQueryOptions } from "./authQueries.ts"; +import { BootstrapForm } from "./BootstrapForm.tsx"; +import { PasswordLoginForm } from "./PasswordLoginForm.tsx"; +import { PendingMfaForm } from "./PendingMfaForm.tsx"; + +/** + * Routes bootstrap, password, and pending-MFA authentication states. + * @returns The current login step or an authenticated redirect. + */ +export function LoginRoute() { + const client = useDashboardTrpcClient(); + const status = useQuery({ + ...authStatusQueryOptions(client), + refetchOnMount: false, + }); + + if (status.isPending) { + return ; + } + if (status.isError) { + return ( + void status.refetch()} + retryBusy={status.isFetching} + status="error" + title="Sign-in unavailable" + /> + ); + } + switch (status.data.state) { + case "anonymous": { + return ; + } + case "authenticated": { + return ; + } + case "bootstrap-required": { + return ; + } + case "pending-mfa": { + return ; + } + } +} diff --git a/greenfield/src/browser/auth/PasswordLoginForm.tsx b/greenfield/src/browser/auth/PasswordLoginForm.tsx new file mode 100644 index 000000000..c08f9191d --- /dev/null +++ b/greenfield/src/browser/auth/PasswordLoginForm.tsx @@ -0,0 +1,107 @@ +import { useForm } from "@tanstack/react-form"; +import { KeyRound } from "lucide-react"; + +import { passwordLoginInputSchema } from "../../contracts/auth.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { LoginPanel } from "./LoginPanel.tsx"; +import { useAuthenticationAction } from "./useAuthenticationAction.ts"; + +/** + * Starts operator authentication with username and password. + * @returns The primary sign-in form. + */ +export function PasswordLoginForm() { + const { busy, client, error, run } = useAuthenticationAction(); + const form = useForm({ + defaultValues: { password: "", username: "" }, + onSubmit: async ({ formApi, value }) => { + await run(async () => { + await client.mutation("auth.login", value); + formApi.setFieldValue("password", ""); + }); + }, + validators: { onSubmit: passwordLoginInputSchema }, + }); + + return ( + + +
void form.handleSubmit()}> +
+ + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + spellCheck={false} + value={field.state.value} + /> + + )} + + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + type="password" + value={field.state.value} + /> + + )} + +
+ [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
+
+ ); +} diff --git a/greenfield/src/browser/auth/PendingMfaForm.tsx b/greenfield/src/browser/auth/PendingMfaForm.tsx new file mode 100644 index 000000000..25938d616 --- /dev/null +++ b/greenfield/src/browser/auth/PendingMfaForm.tsx @@ -0,0 +1,183 @@ +import { useForm } from "@tanstack/react-form"; +import { Fingerprint, LifeBuoy, ShieldCheck, Smartphone } from "lucide-react"; + +import { + recoveryLoginInputSchema, + totpLoginInputSchema, + type AuthStatus, +} from "../../contracts/auth.ts"; +import { useDashboardWebAuthnClient } from "../security/webauthn/webauthnContextValue.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { LoginPanel } from "./LoginPanel.tsx"; +import { useAuthenticationAction } from "./useAuthenticationAction.ts"; + +interface PendingMfaFormProps { + readonly status: Extract; +} + +/** + * Completes pending authentication with one available MFA method. + * @returns TOTP, recovery-code, and WebAuthn second-step controls. + */ +export function PendingMfaForm({ status }: PendingMfaFormProps) { + const { busy, client, error, run } = useAuthenticationAction(); + const webAuthn = useDashboardWebAuthnClient(); + const totpForm = useForm({ + defaultValues: { code: "" }, + onSubmit: async ({ formApi, value }) => { + await run(async () => { + await client.mutation("auth.loginTotp", value); + formApi.setFieldValue("code", ""); + }); + }, + validators: { onSubmit: totpLoginInputSchema }, + }); + const recoveryForm = useForm({ + defaultValues: { code: "" }, + onSubmit: async ({ formApi, value }) => { + await run(async () => { + await client.mutation("auth.loginRecovery", value); + formApi.setFieldValue("code", ""); + }); + }, + validators: { onSubmit: recoveryLoginInputSchema }, + }); + const methods = status.pendingLogin.methods; + const hasTotp = methods.includes("totp"); + const hasRecovery = methods.includes("recovery"); + const hasWebAuthn = methods.includes("webauthn"); + + async function submitWebAuthn() { + await run(async () => { + const challenge = await client.mutation("auth.beginWebAuthnLogin", {}); + const credential = await webAuthn.authenticate(challenge.options); + await client.mutation("auth.loginWebAuthn", { response: credential }); + }); + } + + return ( + + + {hasTotp && ( +
void totpForm.handleSubmit()}> + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + value={field.state.value} + /> + + )} + + + [state.canSubmit, state.isSubmitting] as const + } + > + {([canSubmit, isSubmitting]) => ( + + )} + +
+ )} + {hasRecovery && ( +
void recoveryForm.handleSubmit()} + > + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + spellCheck={false} + type="password" + value={field.state.value} + /> + + )} + + + [state.canSubmit, state.isSubmitting] as const + } + > + {([canSubmit, isSubmitting]) => ( + + )} + +
+ )} + {hasWebAuthn && ( + + )} +
+ ); +} diff --git a/greenfield/src/browser/auth/authQueries.ts b/greenfield/src/browser/auth/authQueries.ts new file mode 100644 index 000000000..32225d0fd --- /dev/null +++ b/greenfield/src/browser/auth/authQueries.ts @@ -0,0 +1,45 @@ +import { queryOptions, type QueryClient } from "@tanstack/react-query"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +export const authStatusQueryKey = ["auth", "status"] as const; + +/** + * Defines the sole cache entry for non-secret browser authentication state. + * @param client Browser contract client. + * @returns TanStack Query options with request cancellation propagation. + */ +export function authStatusQueryOptions(client: DashboardTrpcClient) { + return queryOptions({ + queryFn: ({ signal }) => client.query("auth.status", {}, { signal }), + queryKey: authStatusQueryKey, + retry: false, + staleTime: 0, + }); +} + +/** + * Replaces all cached browser data after an authentication boundary changes. + * @param queryClient Browser-owned query cache. + * @param status Optional known state to seed after clearing. + */ +export function resetAuthenticatedBrowserCache( + queryClient: QueryClient, + status?: AuthStatus +): void { + if (status === undefined) { + queryClient.clear(); + return; + } + for (const query of queryClient.getQueryCache().getAll()) { + if ( + query.queryKey.length === authStatusQueryKey.length && + query.queryKey.every((value, index) => value === authStatusQueryKey[index]) + ) { + continue; + } + queryClient.removeQueries({ exact: true, queryKey: query.queryKey }); + } + queryClient.setQueryData(authStatusQueryKey, status); +} diff --git a/greenfield/src/browser/auth/useAuthenticationAction.ts b/greenfield/src/browser/auth/useAuthenticationAction.ts new file mode 100644 index 000000000..9f9c450d0 --- /dev/null +++ b/greenfield/src/browser/auth/useAuthenticationAction.ts @@ -0,0 +1,44 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; + +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { resetAuthenticatedBrowserCache } from "./authQueries.ts"; + +/** + * Runs one login mutation at a time and promotes successful authentication state. + * @returns The browser client, safe action state, and authentication runner. + */ +export function useAuthenticationAction() { + const client = useDashboardTrpcClient(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const action = useExclusiveDashboardAction(); + + async function refreshAuthenticationStatus() { + const status = await client.query("auth.status", {}); + resetAuthenticatedBrowserCache(queryClient, status); + return status; + } + + async function run(operation: () => Promise): Promise { + const result = await action.run(async () => { + try { + await operation(); + } catch (error: unknown) { + try { + await refreshAuthenticationStatus(); + } catch { + resetAuthenticatedBrowserCache(queryClient); + } + throw error; + } + return refreshAuthenticationStatus(); + }); + if (result.status === "success" && result.value.state === "authenticated") { + await navigate({ replace: true, to: "/" }); + } + } + + return { ...action, client, run }; +} diff --git a/greenfield/src/browser/bootstrap.tsx b/greenfield/src/browser/bootstrap.tsx deleted file mode 100644 index 395fe1506..000000000 --- a/greenfield/src/browser/bootstrap.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { DashboardBrowserApplication } from "./application.tsx"; -import { createDashboardQueryClient } from "./queryClient.ts"; -import { createDashboardRouter } from "./router.tsx"; - -const queryClient = createDashboardQueryClient(); -const router = createDashboardRouter(); - -/** - * Owns browser services constructed once for the application lifetime. - * @returns The composed Dashboard browser application. - */ -export default function DashboardBrowserBootstrap() { - return ; -} diff --git a/greenfield/src/browser/hooks/useExclusiveDashboardAction.ts b/greenfield/src/browser/hooks/useExclusiveDashboardAction.ts new file mode 100644 index 000000000..6f53c22c4 --- /dev/null +++ b/greenfield/src/browser/hooks/useExclusiveDashboardAction.ts @@ -0,0 +1,46 @@ +import { useRef, useState } from "react"; + +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; + +/** Success/failure marker that never retains the rejected value. */ +export type DashboardActionResult = + | Readonly<{ status: "failure" }> + | Readonly<{ status: "success"; value: TValue }>; + +interface DashboardActionState { + readonly busy: boolean; + readonly error: string | undefined; +} + +/** + * Runs one browser security action at a time without using the mutation cache. + * Rejections are reduced immediately to fixed safe text and never retained. + * @returns Exclusive action state and runner. + */ +export function useExclusiveDashboardAction() { + const inFlight = useRef(false); + const [state, setState] = useState({ + busy: false, + error: undefined, + }); + + async function run( + action: () => Promise + ): Promise> { + if (inFlight.current) return { status: "failure" }; + inFlight.current = true; + setState({ busy: true, error: undefined }); + try { + const value = await action(); + setState({ busy: false, error: undefined }); + return { status: "success", value }; + } catch (error: unknown) { + setState({ busy: false, error: dashboardBrowserFailureMessage(error) }); + return { status: "failure" }; + } finally { + inFlight.current = false; + } + } + + return { ...state, run }; +} diff --git a/greenfield/src/browser/index.css b/greenfield/src/browser/index.css index 335dd7429..f8cce5870 100644 --- a/greenfield/src/browser/index.css +++ b/greenfield/src/browser/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@config "../../tailwind.config.ts"; :root { color-scheme: dark; @@ -10,16 +11,52 @@ BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #020617; + background: #0b0b0c; + color: #e7e9ee; } +html, body { + height: 100%; min-width: 20rem; - min-height: 100vh; margin: 0; + overflow: hidden; +} + +#root { + height: 100%; } button, a { -webkit-tap-highlight-color: transparent; } + +:where(button, [role="button"]):not(:disabled):not([aria-disabled="true"]), +:where(a[href], summary, label[for], select), +:where(input[type="checkbox"], input[type="radio"], input[type="file"]) { + cursor: pointer; +} + +:where(button, [role="button"], select, input):disabled, +:where([aria-disabled="true"]) { + cursor: not-allowed; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #121316; +} + +::-webkit-scrollbar-thumb { + background: #2a2d33; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4a505a; +} diff --git a/greenfield/src/browser/index.html b/greenfield/src/browser/index.html index 83bdc350c..7791685c2 100644 --- a/greenfield/src/browser/index.html +++ b/greenfield/src/browser/index.html @@ -8,7 +8,14 @@ -
+
+
+ Loading Dashboard… +
+
diff --git a/greenfield/src/browser/layout/DashboardShell.tsx b/greenfield/src/browser/layout/DashboardShell.tsx new file mode 100644 index 000000000..269c2878c --- /dev/null +++ b/greenfield/src/browser/layout/DashboardShell.tsx @@ -0,0 +1,163 @@ +import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/react"; +import { Outlet, useLocation } from "@tanstack/react-router"; +import { Home, Menu, ShieldCheck, X, type LucideIcon } from "lucide-react"; +import { useState } from "react"; + +import { Icon } from "../ui/Icon.tsx"; +import { IconOnlyButton } from "../ui/IconOnlyButton.tsx"; +import { NavigationLink } from "../ui/NavigationLink.tsx"; + +interface NavigationItem { + readonly icon: LucideIcon; + readonly label: string; + readonly to: "/" | "/account-security"; +} + +const navigationItems: readonly NavigationItem[] = Object.freeze([ + { icon: Home, label: "Dashboard", to: "/" }, + { icon: ShieldCheck, label: "Account security", to: "/account-security" }, +]); + +interface NavigationProps { + readonly currentPath: string; + readonly onNavigate?: () => void; +} + +function Navigation({ currentPath, onNavigate }: NavigationProps) { + return ( + + ); +} + +interface SidebarContentProps extends NavigationProps { + readonly onClose?: () => void; +} + +function SidebarContent({ currentPath, onClose, onNavigate }: SidebarContentProps) { + return ( + <> +
+
+ +

+ Mira Dashboard +

+
+ {onClose !== undefined && ( + + )} +
+ +
+ Secure operator workspace +
+ + ); +} + +/** + * Renders the persistent Dashboard layout and a focused login canvas. + * @returns The current route outlet inside its visual application shell. + */ +export function DashboardShell() { + const location = useLocation(); + const [mobileNavigationPath, setMobileNavigationPath] = useState(); + const mobileNavigationOpen = mobileNavigationPath === location.pathname; + + if (location.pathname === "/login") { + return ( +
+ +
+ ); + } + + const currentTitle = + navigationItems.find((item) => item.to === location.pathname)?.label ?? + "Mira Dashboard"; + return ( +
+ + Skip to content + + + + + setMobileNavigationPath(undefined)} + open={mobileNavigationOpen} + > + +
+ + + Mira Dashboard navigation + + setMobileNavigationPath(undefined)} + onNavigate={() => setMobileNavigationPath(undefined)} + /> + +
+
+ +
+
+ setMobileNavigationPath(location.pathname)} + size="sm" + variant="ghost" + /> +

{currentTitle}

+
+
+
+ +
+
+
+
+ ); +} diff --git a/greenfield/src/browser/lazyBootstrap.tsx b/greenfield/src/browser/lazyBootstrap.tsx deleted file mode 100644 index 12d74a11a..000000000 --- a/greenfield/src/browser/lazyBootstrap.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { lazy } from "react"; - -const DashboardBrowserBootstrap = lazy(() => import("./bootstrap.tsx")); - -/** - * Defers the application providers and route graph behind the minimal document bootstrap. - * @returns The lazy Dashboard application boundary. - */ -export default function LazyDashboardBrowserBootstrap() { - return ; -} diff --git a/greenfield/src/browser/lib/classNames.ts b/greenfield/src/browser/lib/classNames.ts new file mode 100644 index 000000000..dcc1130ec --- /dev/null +++ b/greenfield/src/browser/lib/classNames.ts @@ -0,0 +1,11 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Combines conditional class names and resolves conflicting Tailwind utilities. + * @param values Conditional class-name inputs. + * @returns One normalized class-name string. + */ +export function cn(...values: ClassValue[]): string { + return twMerge(clsx(values)); +} diff --git a/greenfield/src/browser/lib/formatDateTime.ts b/greenfield/src/browser/lib/formatDateTime.ts new file mode 100644 index 000000000..27fb5977f --- /dev/null +++ b/greenfield/src/browser/lib/formatDateTime.ts @@ -0,0 +1,10 @@ +import { format } from "date-fns"; + +/** + * Formats a contract-validated timestamp in the operator browser's local time. + * @param timestampMs Unix epoch milliseconds. + * @returns Stable human-readable local date and time. + */ +export function formatDashboardDateTime(timestampMs: number): string { + return format(new Date(timestampMs), "yyyy-MM-dd HH:mm:ss"); +} diff --git a/greenfield/src/browser/main.test.tsx b/greenfield/src/browser/main.test.tsx index bb4835a61..c2942ecaa 100644 --- a/greenfield/src/browser/main.test.tsx +++ b/greenfield/src/browser/main.test.tsx @@ -1,33 +1,32 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { act } from "react"; -import { acquireBrowserTestEnvironment } from "./testSupport/browserTestEnvironment.ts"; - -const browserEnvironment = await acquireBrowserTestEnvironment(); -const { waitFor } = await import("@testing-library/react"); - -afterAll(async () => { - await browserEnvironment.release(); -}); +const { screen } = await import("@testing-library/react"); describe("Dashboard browser entrypoint", () => { - test("mounts the lazy application graph into the document root", async () => { + test("mounts the composed application into the document root", async () => { document.body.innerHTML = '
'; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve( + Response.json( + { result: { data: { json: { state: "anonymous" } } } }, + { status: 200 } + ) + ); let entrypoint: typeof import("./main.tsx") | undefined; try { entrypoint = await act(async () => import("./main.tsx")); - await waitFor( - () => { - const heading = document.querySelector("h1"); - expect(heading).not.toBeNull(); - expect(heading?.textContent).toBe("Mira Dashboard"); - }, - { container: document.body } - ); + + expect( + await screen.findByRole("heading", { level: 1, name: "Sign in" }) + ).toBeTruthy(); } finally { - const mountedRoot = entrypoint?.dashboardBrowserRoot; - if (mountedRoot) act(() => mountedRoot.unmount()); + if (entrypoint !== undefined) { + act(() => entrypoint?.dashboardBrowserRoot.unmount()); + } + globalThis.fetch = originalFetch; document.body.replaceChildren(); } }); diff --git a/greenfield/src/browser/main.tsx b/greenfield/src/browser/main.tsx index cc1e86550..c1d0ee855 100644 --- a/greenfield/src/browser/main.tsx +++ b/greenfield/src/browser/main.tsx @@ -1,7 +1,7 @@ -import { StrictMode, Suspense } from "react"; +import { StrictMode } from "react"; import { createRoot, type Root } from "react-dom/client"; -import LazyDashboardBrowserBootstrap from "./lazyBootstrap.tsx"; +import DashboardBrowserApplicationRoot from "./application.tsx"; const rootElement = document.querySelector("#root"); if (!(rootElement instanceof HTMLElement)) { @@ -13,17 +13,6 @@ export const dashboardBrowserRoot: Root = createRoot(rootElement); dashboardBrowserRoot.render( - - Loading Dashboard… - - } - > - - + ); diff --git a/greenfield/src/browser/routeComponents.tsx b/greenfield/src/browser/routeComponents.tsx deleted file mode 100644 index 3b97d034e..000000000 --- a/greenfield/src/browser/routeComponents.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Link, Outlet } from "@tanstack/react-router"; - -/** - * Renders the persistent Dashboard navigation and route outlet. - * @returns The accessible application shell. - */ -export function DashboardShell() { - return ( -
- - Skip to content - -
-
- - Mira Dashboard - -

Secure operations workspace

-
-
-
- -
-
- ); -} - -/** - * Renders the Phase 1 browser entry route. - * @returns The initial Dashboard overview. - */ -export function OverviewRoute() { - return ( -
-

Dashboard foundation

-

- Mira Dashboard -

-

- The secure browser workspace is ready for the rewritten Dashboard - features. -

- -

Application shell ready

-

- Feature routes will appear here as their contracts and services are - completed. -

-
-
- ); -} diff --git a/greenfield/src/browser/router.tsx b/greenfield/src/browser/router.tsx index a69b5e9d2..e5a909c80 100644 --- a/greenfield/src/browser/router.tsx +++ b/greenfield/src/browser/router.tsx @@ -5,15 +5,27 @@ import { type RouterHistory, } from "@tanstack/react-router"; -import { DashboardShell, OverviewRoute } from "./routeComponents.tsx"; +import { DashboardShell } from "./layout/DashboardShell.tsx"; +import { LoadingState } from "./ui/LoadingState.tsx"; const rootRoute = createRootRoute({ component: DashboardShell }); const overviewRoute = createRoute({ - component: OverviewRoute, getParentRoute: () => rootRoute, path: "/", -}); -const routeTree = rootRoute.addChildren([overviewRoute]); +}).lazy(() => import("./routes/overview.lazy.tsx").then((module) => module.Route)); +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/login", +}).lazy(() => import("./routes/login.lazy.tsx").then((module) => module.Route)); +const accountSecurityRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/account-security", +}).lazy(() => import("./routes/accountSecurity.lazy.tsx").then((module) => module.Route)); +const routeTree = rootRoute.addChildren([ + overviewRoute, + loginRoute, + accountSecurityRoute, +]); /** * Creates one browser router owned by the browser composition root. @@ -22,6 +34,7 @@ const routeTree = rootRoute.addChildren([overviewRoute]); */ export function createDashboardRouter(history?: RouterHistory) { return createRouter({ + defaultPendingComponent: () => , defaultPreload: "intent", defaultPreloadStaleTime: 30_000, ...(history === undefined ? {} : { history }), diff --git a/greenfield/src/browser/routes/accountSecurity.lazy.tsx b/greenfield/src/browser/routes/accountSecurity.lazy.tsx new file mode 100644 index 000000000..c42543d46 --- /dev/null +++ b/greenfield/src/browser/routes/accountSecurity.lazy.tsx @@ -0,0 +1,14 @@ +import { createLazyRoute } from "@tanstack/react-router"; + +import { AuthenticationBoundary } from "../auth/AuthenticationBoundary.tsx"; +import { AccountSecurityRoute } from "../security/AccountSecurityRoute.tsx"; + +export const Route = createLazyRoute("/account-security")({ + component: function AccountSecurityBoundary() { + return ( + + + + ); + }, +}); diff --git a/greenfield/src/browser/routes/login.lazy.tsx b/greenfield/src/browser/routes/login.lazy.tsx new file mode 100644 index 000000000..8b5173793 --- /dev/null +++ b/greenfield/src/browser/routes/login.lazy.tsx @@ -0,0 +1,5 @@ +import { createLazyRoute } from "@tanstack/react-router"; + +import { LoginRoute } from "../auth/LoginRoute.tsx"; + +export const Route = createLazyRoute("/login")({ component: LoginRoute }); diff --git a/greenfield/src/browser/routes/overview.lazy.tsx b/greenfield/src/browser/routes/overview.lazy.tsx new file mode 100644 index 000000000..50ce1fd25 --- /dev/null +++ b/greenfield/src/browser/routes/overview.lazy.tsx @@ -0,0 +1,53 @@ +import { createLazyRoute } from "@tanstack/react-router"; +import { ShieldCheck } from "lucide-react"; + +import { AuthenticationBoundary } from "../auth/AuthenticationBoundary.tsx"; +import { ActionLink } from "../ui/ActionLink.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { Text } from "../ui/Text.tsx"; + +export const Route = createLazyRoute("/")({ + component: function OverviewRoute() { + return ( + +
+ + + + +
+ Application shell ready + + Authentication, account security, delivery, and + runtime boundaries are composed through the greenfield + application. + +
+
+
+
+ + + Manage account security + +
+
+
+ ); + }, +}); diff --git a/greenfield/src/browser/security/AccountSecurityRoute.test.tsx b/greenfield/src/browser/security/AccountSecurityRoute.test.tsx new file mode 100644 index 000000000..463616875 --- /dev/null +++ b/greenfield/src/browser/security/AccountSecurityRoute.test.tsx @@ -0,0 +1,917 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; +import { act } from "react"; + +import type { + AccountSecuritySummary, + WebAuthnCredentialSummary, +} from "../../contracts/accountSecurity.ts"; +import type { AuthSessionSummary, AuthStatus } from "../../contracts/auth.ts"; +import type { + AutomationCredentialSummary, + AutomationPrincipalSummary, +} from "../../contracts/automationSecurity.ts"; +import type { SecurityAuditEventSummary } from "../../contracts/securityAudit.ts"; +import type { + WebAuthnAuthenticationOptions, + WebAuthnAuthenticationResponse, + WebAuthnRegistrationOptions, + WebAuthnRegistrationResponse, +} from "../../contracts/webauthn.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardBrowserApplication } from "../application.tsx"; +import { createDashboardRouter } from "../router.tsx"; +import type { DashboardWebAuthnClient } from "./webauthn/webauthnClient.ts"; + +const { render, screen, waitFor } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const timestampMs = Date.now(); +const user = Object.freeze({ + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", +}); +const currentSession: AuthSessionSummary = Object.freeze({ + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + userAgent: "Current browser", +}); +const otherSession: AuthSessionSummary = Object.freeze({ + ...currentSession, + authenticatedAtMs: timestampMs - 10_000, + createdAtMs: timestampMs - 10_000, + id: "b".repeat(32), + isCurrent: false, + lastSeenAtMs: timestampMs - 5000, + userAgent: "Other browser", +}); +const authenticatedStatus: AuthStatus = Object.freeze({ + session: currentSession, + state: "authenticated", + user, +}); +const recentVerification = Object.freeze({ + expiresAtMs: timestampMs + 300_000, + recent: true as const, + remainingMs: 300_000, + verifiedAtMs: timestampMs, +}); +const staleVerification = Object.freeze({ recent: false as const }); +const disabledSummary = Object.freeze({ + checkedAtMs: timestampMs, + mfa: { + enabled: false, + methods: [], + recoveryCodesRemaining: 0, + totpFactors: [], + webAuthnCredentials: [], + }, + recentAuth: { mfa: staleVerification, password: recentVerification }, + webAuthn: { available: true, rpId: "localhost" }, +} satisfies AccountSecuritySummary); +const totpFactor = Object.freeze({ + confirmedAtMs: timestampMs, + createdAtMs: timestampMs - 1000, + id: "019fd976-9d52-7a1b-86f4-3fc41d89dedd", + label: "Primary authenticator", +}); +const enabledSummary = Object.freeze({ + checkedAtMs: timestampMs, + mfa: { + enabled: true, + enabledAtMs: timestampMs, + methods: ["recovery", "totp"], + recoveryCodesRemaining: 10, + totpFactors: [totpFactor], + webAuthnCredentials: [], + }, + recentAuth: { mfa: recentVerification, password: recentVerification }, + webAuthn: { available: true, rpId: "localhost" }, +} satisfies AccountSecuritySummary); +const automationCredential = Object.freeze({ + createdAtMs: timestampMs, + id: "019fd979-42cc-7ce4-8392-3de63748a594", + label: "Heartbeat credential", + prefix: "c".repeat(32), +} satisfies AutomationCredentialSummary); +const automationPrincipal = Object.freeze({ + activeCredentialCount: 1, + authorizationVersion: 1, + capabilities: ["notifications:read"], + createdAtMs: timestampMs, + disabled: false, + id: "openclaw-heartbeat", + label: "OpenClaw heartbeat", + totalCredentialCount: 1, + updatedAtMs: timestampMs, +} satisfies AutomationPrincipalSummary); +const authenticationOptions = Object.freeze({ + allowCredentials: [{ id: "AAAAAAAA", type: "public-key" }], + challenge: "A".repeat(32), + rpId: "localhost", + timeout: 60_000, + userVerification: "required", +} satisfies WebAuthnAuthenticationOptions); +const authenticationResponse = Object.freeze({ + authenticatorAttachment: "cross-platform", + clientExtensionResults: {}, + id: "AAAAAAAA", + rawId: "AAAAAAAA", + response: { + authenticatorData: "AAAA", + clientDataJSON: "AAAA", + signature: "AAAA", + }, + type: "public-key", +} satisfies WebAuthnAuthenticationResponse); +const registrationOptions = Object.freeze({ + attestation: "none", + authenticatorSelection: { + authenticatorAttachment: "cross-platform", + requireResidentKey: false, + residentKey: "discouraged", + userVerification: "required", + }, + challenge: "A".repeat(32), + excludeCredentials: [], + extensions: { credProps: true }, + hints: ["security-key"], + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + rp: { id: "localhost", name: "Mira Dashboard" }, + timeout: 60_000, + user: { + displayName: "Operator", + id: "A".repeat(16), + name: "operator", + }, +} satisfies WebAuthnRegistrationOptions); +const registrationResponse = Object.freeze({ + authenticatorAttachment: "cross-platform", + clientExtensionResults: { credProps: { rk: false } }, + id: "BBBBBBBB", + rawId: "BBBBBBBB", + response: { + attestationObject: "AAAA", + authenticatorData: "AAAA", + clientDataJSON: "AAAA", + publicKey: "AAAA", + publicKeyAlgorithm: -7, + transports: ["usb"], + }, + type: "public-key", +} satisfies WebAuthnRegistrationResponse); +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), +}); + +interface TransportCall { + readonly input: unknown; + readonly kind: "mutation" | "query"; + readonly path: string; +} + +class SecurityTransport implements DashboardTrpcTransport { + auditEvents: SecurityAuditEventSummary[] = []; + authStatus: AuthStatus = authenticatedStatus; + readonly calls: TransportCall[] = []; + credentials = new Map(); + mutationHandler: (path: string, input: unknown) => Promise = (path) => + Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + principals: AutomationPrincipalSummary[] = []; + sessions: AuthSessionSummary[] = [currentSession, otherSession]; + summary: AccountSecuritySummary; + + constructor(summary: AccountSecuritySummary = enabledSummary) { + this.summary = summary; + } + + mutation(path: string, input?: unknown): Promise { + this.calls.push({ input, kind: "mutation", path }); + return this.mutationHandler(path, input); + } + + query(path: string, input?: unknown): Promise { + this.calls.push({ input, kind: "query", path }); + switch (path) { + case "accountSecurity.summary": { + return Promise.resolve(this.summary); + } + case "auth.sessions": { + return Promise.resolve({ sessions: this.sessions }); + } + case "auth.status": { + return Promise.resolve(this.authStatus); + } + case "automationSecurity.listCredentials": { + if ( + typeof input !== "object" || + input === null || + !("principalId" in input) || + typeof input.principalId !== "string" + ) { + return Promise.reject(new TypeError("Missing principal")); + } + const { principalId } = input; + const credentials = this.credentials.get(principalId) ?? []; + return Promise.resolve({ + credentials, + principalId, + totalCredentialCount: credentials.length, + }); + } + case "automationSecurity.listPrincipals": { + return Promise.resolve({ + activePrincipalCount: this.principals.filter( + (principal) => !principal.disabled + ).length, + principals: this.principals, + totalPrincipalCount: this.principals.length, + }); + } + case "securityAudit.listEvents": { + return Promise.resolve({ events: this.auditEvents }); + } + default: { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + } + } +} + +const queryClients: ReturnType[] = []; + +function renderAccountSecurity( + transport: SecurityTransport, + webAuthnClient: DashboardWebAuthnClient = unexpectedWebAuthnClient +) { + const queryClient = createDashboardQueryClient(); + queryClients.push(queryClient); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: ["/account-security"] }) + ); + render( + + ); + return queryClient; +} + +function cachedData(queryClient: ReturnType) { + return JSON.stringify( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.state.data) + ); +} + +function recoveryCodes(): string[] { + return Array.from( + { length: 10 }, + (_, index) => + `${index.toString(16).padStart(32, "0")}-${(index + 16).toString(16).padStart(32, "0")}` + ); +} + +async function waitForDialogExit(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + expect(screen.queryByRole("dialog", { hidden: true })).toBeNull(); +} + +afterEach(() => { + for (const queryClient of queryClients.splice(0)) queryClient.clear(); +}); + +describe("Dashboard account security route", () => { + test("renders protected security inventories and redacted audit history", async () => { + const transport = new SecurityTransport(); + transport.auditEvents = [ + { + action: "auth.login", + actor: { + authenticatorId: currentSession.id, + id: user.id, + kind: "user", + }, + id: "019fd977-c837-747d-9693-bcb8e34f6d6c", + metadata: { method: "password" }, + occurredAtMs: timestampMs, + outcome: "succeeded", + target: { id: user.id, type: "user" }, + }, + ]; + renderAccountSecurity(transport); + + await screen.findByRole("heading", { level: 1, name: "Account security" }); + for (const name of [ + "Verification and password", + "Multi-factor authentication", + "Browser sessions", + "Automation credentials", + "Security audit", + ]) { + expect(screen.getByRole("heading", { level: 2, name })).toBeTruthy(); + } + expect(await screen.findByText("auth.login")).toBeTruthy(); + expect(screen.getByText("succeeded")).toBeTruthy(); + expect(screen.getByText(/method=password/u)).toBeTruthy(); + }); + + test("refreshes password and MFA proofs and changes the password ephemerally", async () => { + const transport = new SecurityTransport(); + const webAuthnInputs: WebAuthnAuthenticationOptions[] = []; + const webAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: (options: WebAuthnAuthenticationOptions) => { + webAuthnInputs.push(options); + return Promise.resolve(authenticationResponse); + }, + register: () => Promise.reject(new TypeError("Unexpected registration")), + }); + transport.summary = { + ...enabledSummary, + mfa: { + ...enabledSummary.mfa, + methods: ["recovery", "totp", "webauthn"], + webAuthnCredentials: [ + { + backedUp: false, + createdAtMs: timestampMs, + deviceType: "singleDevice", + id: "019fd978-1e89-7819-b845-0c843bec6937", + label: "Security key", + transports: ["usb"], + usable: true, + }, + ], + }, + }; + const paths: string[] = []; + transport.mutationHandler = (path, input) => { + paths.push(path); + switch (path) { + case "accountSecurity.reauthenticatePassword": { + expect(input).toEqual({ password: "password proof" }); + return Promise.resolve({ + session: currentSession, + verifiedAtMs: timestampMs, + }); + } + case "accountSecurity.stepUpTotp": { + expect(input).toEqual({ code: "123456" }); + return Promise.resolve({ + method: "totp", + session: { ...currentSession, authMethod: "totp" }, + verifiedAtMs: timestampMs, + }); + } + case "accountSecurity.stepUpRecovery": { + expect(input).toEqual({ code: recoveryCodes()[0] }); + return Promise.resolve({ + method: "recovery", + recoveryCodesRemaining: 9, + session: { ...currentSession, authMethod: "recovery" }, + verifiedAtMs: timestampMs, + }); + } + case "accountSecurity.beginWebAuthnStepUp": { + return Promise.resolve({ + expiresAtMs: timestampMs + 60_000, + options: authenticationOptions, + }); + } + case "accountSecurity.stepUpWebAuthn": { + expect(input).toEqual({ response: authenticationResponse }); + return Promise.resolve({ + method: "webauthn", + session: { ...currentSession, authMethod: "webauthn" }, + verifiedAtMs: timestampMs, + }); + } + case "auth.changePassword": { + expect(input).toEqual({ + currentPassword: "current password", + newPassword: "new strong password", + }); + return Promise.resolve({ + revokedSessions: 1, + session: currentSession, + }); + } + default: { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + } + }; + const queryClient = renderAccountSecurity(transport, webAuthnClient); + const userActions = userEvent.setup(); + await screen.findByRole("heading", { level: 1, name: "Account security" }); + + await userActions.type(screen.getByLabelText("Password proof"), "password proof"); + await userActions.click(screen.getByRole("button", { name: "Verify password" })); + await screen.findByText("Recent password verification refreshed."); + + await userActions.type(screen.getByLabelText("Authenticator proof"), "123456"); + await userActions.click( + screen.getByRole("button", { name: "Verify authenticator" }) + ); + await screen.findByText("Recent MFA verification refreshed."); + + await userActions.type( + screen.getByLabelText("Recovery proof"), + recoveryCodes()[0]! + ); + await userActions.click( + screen.getByRole("button", { name: "Use recovery code" }) + ); + await screen.findByText("Recovery proof accepted and recent MFA refreshed."); + + await userActions.click( + screen.getByRole("button", { name: "Verify security key" }) + ); + await screen.findByText("Security-key verification refreshed recent MFA."); + + await userActions.type( + screen.getByLabelText("Current password"), + "current password" + ); + await userActions.type( + screen.getByLabelText("New password"), + "new strong password" + ); + await userActions.click(screen.getByRole("button", { name: "Change password" })); + await screen.findByText("Password changed and other sessions revoked."); + + expect(paths).toEqual([ + "accountSecurity.reauthenticatePassword", + "accountSecurity.stepUpTotp", + "accountSecurity.stepUpRecovery", + "accountSecurity.beginWebAuthnStepUp", + "accountSecurity.stepUpWebAuthn", + "auth.changePassword", + ]); + expect(webAuthnInputs).toEqual([authenticationOptions]); + for (const secret of [ + "password proof", + "123456", + recoveryCodes()[0]!, + "current password", + "new strong password", + ]) { + expect(cachedData(queryClient)).not.toContain(secret); + } + }); + + test("enrolls TOTP and reveals recovery codes only in component state", async () => { + const transport = new SecurityTransport(disabledSummary); + const enrollment = { + expiresAtMs: timestampMs + 300_000, + factorId: totpFactor.id, + label: "Phone authenticator", + otpauthUri: + "otpauth://totp/Mira:operator?secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + secret: "A".repeat(32), + }; + const codes = recoveryCodes(); + transport.mutationHandler = (path, input) => { + if (path === "accountSecurity.beginTotpEnrollment") { + expect(input).toEqual({ label: "Phone authenticator" }); + return Promise.resolve({ enrollment }); + } + expect(path).toBe("accountSecurity.confirmTotpEnrollment"); + expect(input).toEqual({ code: "123456", factorId: totpFactor.id }); + transport.summary = enabledSummary; + return Promise.resolve({ + enabledNow: true, + factor: totpFactor, + recoveryCodes: codes, + revokedSessions: 0, + session: { ...currentSession, authMethod: "totp" }, + }); + }; + const queryClient = renderAccountSecurity(transport); + const userActions = userEvent.setup(); + await screen.findByRole("heading", { level: 1, name: "Account security" }); + + await userActions.type( + screen.getByLabelText("Authenticator label"), + "Phone authenticator" + ); + await userActions.click( + screen.getByRole("button", { name: "Begin authenticator enrollment" }) + ); + expect(await screen.findByText(enrollment.secret)).toBeTruthy(); + expect(cachedData(queryClient)).not.toContain(enrollment.secret); + + await userActions.type(screen.getByLabelText("Confirmation code"), "123456"); + await userActions.click( + screen.getByRole("button", { name: "Confirm authenticator" }) + ); + expect( + await screen.findByRole("heading", { level: 3, name: "New recovery codes" }) + ).toBeTruthy(); + expect(screen.getByText(codes[0]!)).toBeTruthy(); + expect(cachedData(queryClient)).not.toContain(codes[0]!); + await userActions.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText(codes[0]!)).toBeNull(); + }); + + test("enrolls a WebAuthn credential through the injected browser boundary", async () => { + const transport = new SecurityTransport(enabledSummary); + const registrationInputs: WebAuthnRegistrationOptions[] = []; + const webAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => + Promise.reject(new TypeError("Unexpected authentication")), + register: (options: WebAuthnRegistrationOptions) => { + registrationInputs.push(options); + return Promise.resolve(registrationResponse); + }, + }); + const credential = { + backedUp: false, + createdAtMs: timestampMs, + deviceType: "singleDevice" as const, + id: "019fd978-1e89-7819-b845-0c843bec6937", + label: "Primary security key", + transports: ["usb" as const], + usable: true, + } satisfies WebAuthnCredentialSummary; + transport.mutationHandler = (path, input) => { + if (path === "accountSecurity.beginWebAuthnEnrollment") { + expect(input).toEqual({}); + return Promise.resolve({ + expiresAtMs: timestampMs + 60_000, + options: registrationOptions, + }); + } + expect(path).toBe("accountSecurity.confirmWebAuthnEnrollment"); + expect(input).toEqual({ + label: credential.label, + response: registrationResponse, + }); + transport.summary = { + ...enabledSummary, + mfa: { + ...enabledSummary.mfa, + methods: ["recovery", "totp", "webauthn"], + webAuthnCredentials: [credential], + }, + }; + return Promise.resolve({ credential, enabledNow: false }); + }; + renderAccountSecurity(transport, webAuthnClient); + const userActions = userEvent.setup(); + await screen.findByRole("heading", { level: 1, name: "Account security" }); + + await userActions.type( + screen.getByLabelText("Security-key label"), + credential.label + ); + await userActions.click( + screen.getByRole("button", { name: "Enroll security key" }) + ); + + expect(await screen.findByText(credential.label)).toBeTruthy(); + expect(registrationInputs).toEqual([registrationOptions]); + }); + + test("confirms destructive MFA actions before mutation", async () => { + const securityKey = { + backedUp: false, + createdAtMs: timestampMs, + deviceType: "singleDevice" as const, + id: "019fda70-b47b-7a29-b2a7-f15d32c2bfe2", + label: "Backup security key", + transports: ["usb" as const], + usable: true, + } satisfies WebAuthnCredentialSummary; + const mixedMfaSummary = { + ...enabledSummary, + mfa: { + ...enabledSummary.mfa, + methods: ["recovery", "totp", "webauthn"] as const, + webAuthnCredentials: [securityKey], + }, + } satisfies AccountSecuritySummary; + const transport = new SecurityTransport(mixedMfaSummary); + const codes = recoveryCodes(); + transport.mutationHandler = (path, input) => { + switch (path) { + case "accountSecurity.removeTotpFactor": { + expect(input).toEqual({ factorId: totpFactor.id }); + transport.summary = { + ...mixedMfaSummary, + mfa: { + ...mixedMfaSummary.mfa, + methods: ["recovery", "webauthn"], + totpFactors: [], + }, + }; + return Promise.resolve({ factorId: totpFactor.id, removed: true }); + } + case "accountSecurity.rotateRecoveryCodes": { + expect(input).toEqual({}); + return Promise.resolve({ recoveryCodes: codes }); + } + default: { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + } + }; + renderAccountSecurity(transport); + const userActions = userEvent.setup(); + await screen.findByText(totpFactor.label); + + await userActions.click( + screen.getByRole("button", { + name: `Remove authenticator ${totpFactor.label}`, + }) + ); + expect( + transport.calls.filter( + (call) => call.path === "accountSecurity.removeTotpFactor" + ) + ).toHaveLength(0); + await userActions.click( + screen.getByRole("button", { name: "Remove authenticator" }) + ); + await waitFor(() => expect(screen.queryByText(totpFactor.label)).toBeNull()); + await waitForDialogExit(); + + await userActions.click( + screen.getByRole("button", { name: "Rotate recovery codes" }) + ); + expect( + transport.calls.filter( + (call) => call.path === "accountSecurity.rotateRecoveryCodes" + ) + ).toHaveLength(0); + await userActions.click( + screen.getByRole("button", { name: "Rotate recovery codes" }) + ); + await waitForDialogExit(); + expect(screen.getByText(codes[0]!)).toBeTruthy(); + await userActions.click(screen.getByRole("button", { name: "Dismiss" })); + }); + + test("confirms destructive automation actions before mutation", async () => { + const transport = new SecurityTransport(); + transport.principals = [automationPrincipal]; + transport.credentials.set(automationPrincipal.id, [automationCredential]); + transport.mutationHandler = (path, input) => { + if (path === "automationSecurity.revokeCredential") { + expect(input).toEqual({ + credentialId: automationCredential.id, + expectedAuthorizationVersion: + automationPrincipal.authorizationVersion, + principalId: automationPrincipal.id, + }); + const credential = { + ...automationCredential, + revokedAtMs: timestampMs + 1000, + }; + transport.credentials.set(automationPrincipal.id, [credential]); + return Promise.resolve({ credential, revoked: true }); + } + expect(path).toBe("automationSecurity.disablePrincipal"); + expect(input).toEqual({ + expectedAuthorizationVersion: automationPrincipal.authorizationVersion, + principalId: automationPrincipal.id, + }); + const principal = { + ...automationPrincipal, + activeCredentialCount: 0, + authorizationVersion: 2, + disabled: true as const, + disabledAtMs: timestampMs + 2000, + updatedAtMs: timestampMs + 2000, + }; + transport.principals = [principal]; + return Promise.resolve({ + changed: true, + principal, + revokedCredentials: 0, + }); + }; + renderAccountSecurity(transport); + const userActions = userEvent.setup(); + await screen.findByText(automationPrincipal.label); + + await userActions.click( + screen.getByRole("button", { name: /Manage credentials/u }) + ); + await screen.findByText(automationCredential.label); + await userActions.click( + screen.getByRole("button", { + name: `Revoke credential ${automationCredential.label}`, + }) + ); + expect( + transport.calls.filter( + (call) => call.path === "automationSecurity.revokeCredential" + ) + ).toHaveLength(0); + await userActions.click( + screen.getByRole("button", { name: "Revoke credential" }) + ); + expect(await screen.findByText(/revoked /u)).toBeTruthy(); + await waitForDialogExit(); + expect( + transport.calls.filter( + (call) => call.path === "automationSecurity.revokeCredential" + ) + ).toHaveLength(1); + + await userActions.click( + screen.getByRole("button", { + name: `Disable principal ${automationPrincipal.label}`, + }) + ); + expect( + transport.calls.filter( + (call) => call.path === "automationSecurity.disablePrincipal" + ) + ).toHaveLength(0); + await userActions.click( + screen.getByRole("button", { name: "Disable principal" }) + ); + await waitFor(() => + expect( + transport.calls.filter( + (call) => call.path === "automationSecurity.disablePrincipal" + ) + ).toHaveLength(1) + ); + await waitForDialogExit(); + expect(await screen.findByText(/^Disabled ·/u)).toBeTruthy(); + expect( + transport.calls.filter( + (call) => call.path === "automationSecurity.disablePrincipal" + ) + ).toHaveLength(1); + }); + + test("marks expired automation credentials unusable", async () => { + const transport = new SecurityTransport(); + const expiredCredential = Object.freeze({ + ...automationCredential, + createdAtMs: timestampMs - 10_000, + expiresAtMs: timestampMs - 1, + }); + transport.principals = [ + Object.freeze({ + ...automationPrincipal, + activeCredentialCount: 0, + }), + ]; + transport.credentials.set(automationPrincipal.id, [expiredCredential]); + renderAccountSecurity(transport); + const userActions = userEvent.setup(); + await screen.findByText(automationPrincipal.label); + + await userActions.click( + screen.getByRole("button", { name: /Manage credentials/u }) + ); + + const credentialLabel = await screen.findByText(expiredCredential.label); + const credentialItem = credentialLabel.closest("li"); + expect(credentialItem?.textContent).toContain("expired "); + expect( + screen.queryByRole("button", { + name: `Stage replacement for ${expiredCredential.label}`, + }) + ).toBeNull(); + expect( + screen.queryByRole("button", { + name: `Revoke credential ${expiredCredential.label}`, + }) + ).toBeNull(); + }); + + test("revokes one, other, and all browser sessions with final cache teardown", async () => { + const transport = new SecurityTransport(); + transport.mutationHandler = (path, input) => { + if (path === "auth.revokeSession") { + expect(input).toEqual({ sessionId: otherSession.id }); + transport.sessions = [currentSession]; + return Promise.resolve({ revoked: true }); + } + if (path === "auth.revokeOtherSessions") { + transport.sessions = [currentSession]; + return Promise.resolve({ revokedSessions: 0 }); + } + expect(path).toBe("auth.revokeAllSessions"); + transport.sessions = []; + transport.authStatus = { state: "anonymous" }; + return Promise.resolve({ revokedSessions: 1 }); + }; + const queryClient = renderAccountSecurity(transport); + const userActions = userEvent.setup(); + await screen.findByText("Other browser"); + + await userActions.click( + screen.getByRole("button", { name: "Revoke session Other browser" }) + ); + expect( + screen.getByRole("dialog", { name: "Revoke browser session?" }) + ).toBeTruthy(); + expect( + transport.calls.filter((call) => call.path === "auth.revokeSession") + ).toHaveLength(0); + await userActions.click(screen.getByRole("button", { name: "Revoke session" })); + await waitFor(() => expect(screen.queryByText("Other browser")).toBeNull()); + await userActions.click( + screen.getByRole("button", { name: "Revoke other sessions" }) + ); + await userActions.click( + screen.getByRole("button", { name: "Revoke other sessions" }) + ); + await waitFor(() => + expect( + transport.calls.filter((call) => call.path === "auth.revokeOtherSessions") + ).toHaveLength(1) + ); + await userActions.click( + screen.getByRole("button", { name: "Revoke every session" }) + ); + await userActions.click( + screen.getByRole("button", { name: "Revoke every session" }) + ); + + await screen.findByRole("heading", { level: 1, name: "Sign in" }); + expect(queryClient.getQueryCache().getAll()).toHaveLength(1); + expect( + transport.calls + .filter((call) => call.kind === "mutation") + .map((call) => call.path) + ).toEqual([ + "auth.revokeSession", + "auth.revokeOtherSessions", + "auth.revokeAllSessions", + ]); + }); + + test("reveals a created automation token once without placing it in query data", async () => { + const transport = new SecurityTransport(); + const token = `${automationCredential.prefix}.${"d".repeat(64)}`; + transport.mutationHandler = (path, input) => { + expect(path).toBe("automationSecurity.createPrincipal"); + expect(input).toEqual({ + capabilities: ["notifications:read"], + id: automationPrincipal.id, + initialCredential: { label: automationCredential.label }, + label: automationPrincipal.label, + }); + transport.principals = [automationPrincipal]; + transport.credentials.set(automationPrincipal.id, [automationCredential]); + return Promise.resolve({ + credential: automationCredential, + principal: automationPrincipal, + token, + }); + }; + const queryClient = renderAccountSecurity(transport); + const userActions = userEvent.setup(); + await screen.findByRole("heading", { level: 1, name: "Account security" }); + + await userActions.type( + screen.getByLabelText("Principal ID"), + automationPrincipal.id + ); + await userActions.type( + screen.getByLabelText("Principal label"), + automationPrincipal.label + ); + await userActions.type( + screen.getByLabelText("Initial credential label"), + automationCredential.label + ); + await userActions.click(screen.getByLabelText("notifications:read")); + await userActions.click( + screen.getByRole("button", { name: "Create principal and credential" }) + ); + + expect(await screen.findByText(token)).toBeTruthy(); + expect(cachedData(queryClient)).not.toContain(token); + await userActions.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText(token)).toBeNull(); + expect(await screen.findByText(automationPrincipal.label)).toBeTruthy(); + }); +}); diff --git a/greenfield/src/browser/security/AccountSecurityRoute.tsx b/greenfield/src/browser/security/AccountSecurityRoute.tsx new file mode 100644 index 000000000..c8af45826 --- /dev/null +++ b/greenfield/src/browser/security/AccountSecurityRoute.tsx @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { AutomationSecuritySection } from "./AutomationSecuritySection.tsx"; +import { MfaManagementSection } from "./MfaManagementSection.tsx"; +import { SecurityAuditSection } from "./SecurityAuditSection.tsx"; +import { accountSecuritySummaryQueryOptions } from "./securityQueries.ts"; +import { SecurityVerificationSection } from "./SecurityVerificationSection.tsx"; +import { SessionManagementSection } from "./SessionManagementSection.tsx"; + +/** + * Composes the Phase 2 operator security surface from contract-backed sections. + * @returns The protected account-security route. + */ +export function AccountSecurityRoute() { + const client = useDashboardTrpcClient(); + const summary = useQuery(accountSecuritySummaryQueryOptions(client)); + + if (summary.isPending) { + return ; + } + if (summary.isError) { + return ( + void summary.refetch()} + retryBusy={summary.isFetching} + status="error" + title="Account security unavailable" + /> + ); + } + + return ( +
+ +
+ + + + + +
+
+ ); +} diff --git a/greenfield/src/browser/security/AutomationCapabilityPicker.tsx b/greenfield/src/browser/security/AutomationCapabilityPicker.tsx new file mode 100644 index 000000000..70d4d4571 --- /dev/null +++ b/greenfield/src/browser/security/AutomationCapabilityPicker.tsx @@ -0,0 +1,45 @@ +import { Fieldset, Legend } from "@headlessui/react"; + +import { + type ApplicationCapability, + applicationCapabilities, +} from "../../contracts/security.ts"; +import { Checkbox } from "../ui/Checkbox.tsx"; + +interface AutomationCapabilityPickerProps { + readonly disabled?: boolean; + readonly onChange: (capabilities: ApplicationCapability[]) => void; + readonly value: readonly ApplicationCapability[]; +} + +/** + * Renders the shared capability selector for automation-principal forms. + * @returns An accessible checkbox fieldset for the supported capabilities. + */ +export function AutomationCapabilityPicker({ + disabled, + onChange, + value, +}: AutomationCapabilityPickerProps) { + return ( +
+ Capabilities +
+ {applicationCapabilities.map((capability) => ( + + onChange( + checked + ? [...value, capability].toSorted() + : value.filter((item) => item !== capability) + ) + } + /> + ))} +
+
+ ); +} diff --git a/greenfield/src/browser/security/AutomationCredentialPanel.tsx b/greenfield/src/browser/security/AutomationCredentialPanel.tsx new file mode 100644 index 000000000..464ff93fa --- /dev/null +++ b/greenfield/src/browser/security/AutomationCredentialPanel.tsx @@ -0,0 +1,354 @@ +import { useForm } from "@tanstack/react-form"; +import { + infiniteQueryOptions, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import { + automationCredentialSettingsSchema, + type AutomationCredentialCursor, + type AutomationCredentialSummary, + type AutomationPrincipalSummary, + type ListAutomationCredentialsResult, +} from "../../contracts/automationSecurity.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { ConfirmModal } from "../ui/ConfirmModal.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { revealIssuedAutomationToken } from "./issuedAutomationToken.ts"; +import { + automationCredentialsQueryKey, + refreshSecurityQueries, +} from "./securityQueries.ts"; + +interface AutomationCredentialPanelProps { + readonly onIssuedToken: (token: string) => void; + readonly principal: AutomationPrincipalSummary; +} + +function isCredentialUsable( + credential: AutomationCredentialSummary, + checkedAtMs: number +): boolean { + return ( + credential.revokedAtMs === undefined && + (credential.expiresAtMs === undefined || credential.expiresAtMs > checkedAtMs) + ); +} + +function credentialStatus( + credential: AutomationCredentialSummary, + checkedAtMs: number +): string { + if (credential.revokedAtMs !== undefined) { + return `revoked ${formatDashboardDateTime(credential.revokedAtMs)}`; + } + if (credential.expiresAtMs !== undefined && credential.expiresAtMs <= checkedAtMs) { + return `expired ${formatDashboardDateTime(credential.expiresAtMs)}`; + } + return "active"; +} + +/** + * Manages the credential lifecycle for one automation principal. + * @returns A paginated credential inventory and create/rotate/revoke controls. + */ +export function AutomationCredentialPanel({ + onIssuedToken, + principal, +}: AutomationCredentialPanelProps) { + const action = useExclusiveDashboardAction(); + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const [credentialConfirmation, setCredentialConfirmation] = useState< + Readonly<{ credentialId: string; label: string }> | undefined + >(); + const credentials = useInfiniteQuery( + infiniteQueryOptions({ + initialPageParam: undefined as AutomationCredentialCursor | undefined, + queryFn: ({ pageParam, signal }): Promise => + client.query( + "automationSecurity.listCredentials", + pageParam === undefined + ? { limit: 50, principalId: principal.id } + : { cursor: pageParam, limit: 50, principalId: principal.id }, + { signal } + ), + getNextPageParam: (lastPage) => lastPage.nextCursor, + queryKey: automationCredentialsQueryKey(principal.id), + retry: false, + staleTime: 0, + }) + ); + + async function complete(operation: () => Promise): Promise { + await action.run(async () => { + await operation(); + await refreshSecurityQueries(queryClient); + }); + } + + const credentialForm = useForm({ + defaultValues: { label: "" }, + onSubmit: async ({ formApi, value }) => { + const result = await action.run(async () => { + const created = await client.mutation( + "automationSecurity.createCredential", + { + credential: value, + expectedAuthorizationVersion: principal.authorizationVersion, + principalId: principal.id, + } + ); + await revealIssuedAutomationToken(created.token, onIssuedToken, () => + refreshSecurityQueries(queryClient) + ); + }); + if (result.status === "success") { + formApi.setFieldValue("label", ""); + } + }, + validators: { onSubmit: automationCredentialSettingsSchema }, + }); + + async function rotateCredential(credentialId: string, label: string) { + const result = await action.run(async () => { + const rotated = await client.mutation("automationSecurity.rotateCredential", { + credentialId, + expectedAuthorizationVersion: principal.authorizationVersion, + principalId: principal.id, + replacement: { label }, + }); + await revealIssuedAutomationToken(rotated.token, onIssuedToken, () => + refreshSecurityQueries(queryClient) + ); + }); + if (result.status === "success") { + credentialForm.setFieldValue("label", ""); + } + } + + async function revokeCredential(credentialId: string) { + await complete(() => + client.mutation("automationSecurity.revokeCredential", { + credentialId, + expectedAuthorizationVersion: principal.authorizationVersion, + principalId: principal.id, + }) + ); + } + + async function confirmCredentialRevocation() { + const credential = credentialConfirmation; + if (credential === undefined) return; + try { + await revokeCredential(credential.credentialId); + } finally { + setCredentialConfirmation(undefined); + } + } + + return ( +
+ + {credentials.isPending && ( + + )} + {credentials.isError && ( +
+ + +
+ )} + + [state.canSubmit, state.isSubmitting, state.values.label] as const + } + > + {([canSubmit, isSubmitting, credentialLabel]) => ( + <> + {credentials.isSuccess && + credentials.data.pages.every( + (page) => page.credentials.length === 0 + ) && ( + + )} + {credentials.isSuccess && ( +
    + {credentials.data.pages.flatMap((page) => + page.credentials.map((credential) => { + const usable = isCredentialUsable( + credential, + credentials.dataUpdatedAt + ); + return ( +
  • +

    + {credential.label} +

    +

    + Created{" "} + {formatDashboardDateTime( + credential.createdAtMs + )}{" "} + ·{" "} + {credentialStatus( + credential, + credentials.dataUpdatedAt + )} +

    + {!principal.disabled && usable && ( +
    + + +
    + )} +
  • + ); + }) + )} +
+ )} + {credentials.hasNextPage && ( + + )} + {!principal.disabled && ( +
void credentialForm.handleSubmit()} + > + + {(field) => ( + + + field.handleChange( + event.currentTarget.value + ) + } + required + value={field.state.value} + /> + + )} + + +
+ )} + + )} +
+ setCredentialConfirmation(undefined)} + onConfirm={() => void confirmCredentialRevocation()} + open={credentialConfirmation !== undefined} + title="Revoke automation credential?" + /> +
+ ); +} diff --git a/greenfield/src/browser/security/AutomationPrincipalCard.test.tsx b/greenfield/src/browser/security/AutomationPrincipalCard.test.tsx new file mode 100644 index 000000000..b7a7de900 --- /dev/null +++ b/greenfield/src/browser/security/AutomationPrincipalCard.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +import { QueryClientProvider } from "@tanstack/react-query"; + +import type { AutomationPrincipalSummary } from "../../contracts/automationSecurity.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { createDashboardTrpcClient } from "../api/trpcClient.ts"; +import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; +import { AutomationPrincipalCard } from "./AutomationPrincipalCard.tsx"; + +const { render, screen } = await import("@testing-library/react"); + +const timestampMs = Date.now(); +const principal = Object.freeze({ + activeCredentialCount: 0, + authorizationVersion: 1, + capabilities: ["notifications:read"], + createdAtMs: timestampMs, + disabled: false, + id: "openclaw-heartbeat", + label: "OpenClaw heartbeat", + totalCredentialCount: 0, + updatedAtMs: timestampMs, +} satisfies AutomationPrincipalSummary); + +function principalCard( + queryClient: ReturnType, + currentPrincipal: AutomationPrincipalSummary +) { + const client = createDashboardTrpcClient({ + mutation() { + return Promise.reject(new TypeError("Unexpected mutation")); + }, + query(path, input) { + if (path !== "automationSecurity.listCredentials") { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + return Promise.resolve({ + credentials: [], + principalId: + typeof input === "object" && + input !== null && + "principalId" in input && + typeof input.principalId === "string" + ? input.principalId + : currentPrincipal.id, + totalCredentialCount: 0, + }); + }, + }); + return ( + + +
    + +
+
+
+ ); +} + +describe("automation principal card", () => { + test("resets its capability draft when the server version changes", () => { + const queryClient = createDashboardQueryClient(); + const rendered = render(principalCard(queryClient, principal)); + + try { + expect( + screen.getByRole("checkbox", { name: "notifications:read" }) + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "reports:read" }) + ).not.toBeChecked(); + + rendered.rerender( + principalCard(queryClient, { + ...principal, + authorizationVersion: 2, + capabilities: ["reports:read"], + updatedAtMs: timestampMs + 1000, + }) + ); + + expect(screen.getByRole("checkbox", { name: "reports:read" })).toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "notifications:read" }) + ).not.toBeChecked(); + } finally { + queryClient.clear(); + } + }); +}); diff --git a/greenfield/src/browser/security/AutomationPrincipalCard.tsx b/greenfield/src/browser/security/AutomationPrincipalCard.tsx new file mode 100644 index 000000000..bb2a7cfd2 --- /dev/null +++ b/greenfield/src/browser/security/AutomationPrincipalCard.tsx @@ -0,0 +1,171 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { KeyRound, ShieldOff } from "lucide-react"; +import { useState } from "react"; + +import type { AutomationPrincipalSummary } from "../../contracts/automationSecurity.ts"; +import type { ApplicationCapability } from "../../contracts/security.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { ConfirmModal } from "../ui/ConfirmModal.tsx"; +import { ExpandableCard } from "../ui/ExpandableCard.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { AutomationCapabilityPicker } from "./AutomationCapabilityPicker.tsx"; +import { AutomationCredentialPanel } from "./AutomationCredentialPanel.tsx"; +import { refreshSecurityQueries } from "./securityQueries.ts"; +import { OneTimeSecretPanel } from "./SecurityUi.tsx"; + +interface AutomationPrincipalCardProps { + readonly principal: AutomationPrincipalSummary; +} + +interface AutomationCapabilityEditorProps { + readonly busy: boolean; + readonly disabled: boolean; + readonly initialCapabilities: readonly ApplicationCapability[]; + readonly onReplace: (capabilities: ApplicationCapability[]) => Promise; +} + +/** + * Owns an editable capability draft for one exact server authorization version. + * @returns Capability controls that reset when their keyed server version changes. + */ +function AutomationCapabilityEditor({ + busy, + disabled, + initialCapabilities, + onReplace, +}: AutomationCapabilityEditorProps) { + const [capabilities, setCapabilities] = useState([ + ...initialCapabilities, + ]); + + return ( + <> + + {!disabled && ( + + )} + + ); +} + +/** + * Renders one automation principal and its mutable security controls. + * @returns A principal card with capability and credential management. + */ +export function AutomationPrincipalCard({ principal }: AutomationPrincipalCardProps) { + const action = useExclusiveDashboardAction(); + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const [disableConfirmationOpen, setDisableConfirmationOpen] = useState(false); + const [issuedToken, setIssuedToken] = useState(); + + async function replaceCapabilities(capabilities: ApplicationCapability[]) { + await action.run(async () => { + await client.mutation("automationSecurity.replaceCapabilities", { + capabilities, + expectedAuthorizationVersion: principal.authorizationVersion, + principalId: principal.id, + }); + await refreshSecurityQueries(queryClient); + }); + } + + async function disablePrincipal() { + try { + await action.run(async () => { + await client.mutation("automationSecurity.disablePrincipal", { + expectedAuthorizationVersion: principal.authorizationVersion, + principalId: principal.id, + }); + await refreshSecurityQueries(queryClient); + }); + } finally { + setDisableConfirmationOpen(false); + } + } + + return ( +
  • +
    +
    + {principal.label} +

    + {principal.id} +

    +

    + {principal.disabled ? "Disabled" : "Active"} ·{" "} + {principal.activeCredentialCount} active credential(s) +

    +
    + {!principal.disabled && ( + + )} +
    + + {issuedToken !== undefined && ( + setIssuedToken(undefined)} + title="New automation token" + > + {issuedToken} + + )} + + + + + setDisableConfirmationOpen(false)} + onConfirm={() => void disablePrincipal()} + open={disableConfirmationOpen} + title="Disable automation principal?" + /> +
  • + ); +} diff --git a/greenfield/src/browser/security/AutomationSecuritySection.tsx b/greenfield/src/browser/security/AutomationSecuritySection.tsx new file mode 100644 index 000000000..abb5da949 --- /dev/null +++ b/greenfield/src/browser/security/AutomationSecuritySection.tsx @@ -0,0 +1,256 @@ +import { useForm } from "@tanstack/react-form"; +import { + infiniteQueryOptions, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Bot, Plus, RefreshCw } from "lucide-react"; +import { useState } from "react"; + +import { + createAutomationPrincipalInputSchema, + type AutomationPrincipalCursor, + type ListAutomationPrincipalsResult, +} from "../../contracts/automationSecurity.ts"; +import type { ApplicationCapability } from "../../contracts/security.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { AutomationCapabilityPicker } from "./AutomationCapabilityPicker.tsx"; +import { AutomationPrincipalCard } from "./AutomationPrincipalCard.tsx"; +import { revealIssuedAutomationToken } from "./issuedAutomationToken.ts"; +import { + automationPrincipalsQueryKey, + refreshSecurityQueries, +} from "./securityQueries.ts"; +import { OneTimeSecretPanel, SecuritySection } from "./SecurityUi.tsx"; + +/** + * Renders cursor-paginated automation identities and one-time credential issuance. + * @returns The automation-security management section. + */ +export function AutomationSecuritySection() { + const action = useExclusiveDashboardAction(); + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const [issuedToken, setIssuedToken] = useState(); + const principals = useInfiniteQuery( + infiniteQueryOptions({ + initialPageParam: undefined as AutomationPrincipalCursor | undefined, + queryFn: ({ pageParam, signal }): Promise => + client.query( + "automationSecurity.listPrincipals", + pageParam === undefined + ? { limit: 50 } + : { cursor: pageParam, limit: 50 }, + { signal } + ), + getNextPageParam: (lastPage) => lastPage.nextCursor, + queryKey: automationPrincipalsQueryKey, + retry: false, + staleTime: 0, + }) + ); + const principalForm = useForm({ + defaultValues: { + capabilities: [] as ApplicationCapability[], + id: "", + initialCredential: { label: "" }, + label: "", + }, + onSubmit: async ({ formApi, value }) => { + const result = await action.run(async () => { + const created = await client.mutation( + "automationSecurity.createPrincipal", + value + ); + await revealIssuedAutomationToken( + created.token, + (token) => setIssuedToken(token), + () => refreshSecurityQueries(queryClient) + ); + }); + if (result.status === "success") { + formApi.reset(); + } + }, + validators: { onSubmit: createAutomationPrincipalInputSchema }, + }); + + return ( + + + {issuedToken !== undefined && ( + setIssuedToken(undefined)} + title="Initial automation token" + > + {issuedToken} + + )} +
    void principalForm.handleSubmit()} + > + Create principal +
    + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + spellCheck={false} + value={field.state.value} + /> + + )} + + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + value={field.state.value} + /> + + )} + + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + value={field.state.value} + /> + + )} + +
    + + {(field) => ( + + )} + + [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + {principals.isPending && ( + + )} + {principals.isError && ( +
    + + +
    + )} + {principals.isSuccess && + principals.data.pages.every((page) => page.principals.length === 0) && ( + + )} + {principals.isSuccess && ( +
      + {principals.data.pages.flatMap((page) => + page.principals.map((principal) => ( + + )) + )} +
    + )} + {principals.hasNextPage && ( + + )} +
    + ); +} diff --git a/greenfield/src/browser/security/MfaManagementSection.tsx b/greenfield/src/browser/security/MfaManagementSection.tsx new file mode 100644 index 000000000..e61f88ecc --- /dev/null +++ b/greenfield/src/browser/security/MfaManagementSection.tsx @@ -0,0 +1,216 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { + AccountSecuritySummary, + TotpEnrollment, +} from "../../contracts/accountSecurity.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { ConfirmModal } from "../ui/ConfirmModal.tsx"; +import { MfaRecoveryControls } from "./MfaRecoveryControls.tsx"; +import { refreshSecurityQueries } from "./securityQueries.ts"; +import { OneTimeSecretPanel, SecuritySection } from "./SecurityUi.tsx"; +import { TotpFactorManagement } from "./TotpFactorManagement.tsx"; +import { WebAuthnFactorManagement } from "./WebAuthnFactorManagement.tsx"; + +type MfaConfirmation = + | Readonly<{ factorId: string; kind: "remove-totp"; label: string }> + | Readonly<{ credentialId: string; kind: "remove-webauthn"; label: string }> + | Readonly<{ kind: "rotate-recovery-codes" }>; + +function mfaConfirmationCopy(confirmation: MfaConfirmation) { + switch (confirmation.kind) { + case "remove-totp": { + return { + confirmLabel: "Remove authenticator", + description: `Remove “${confirmation.label}” from the accepted MFA factors.`, + title: "Remove authenticator?", + }; + } + case "remove-webauthn": { + return { + confirmLabel: "Remove security key", + description: `Remove “${confirmation.label}” from the accepted MFA factors.`, + title: "Remove security key?", + }; + } + case "rotate-recovery-codes": { + return { + confirmLabel: "Rotate recovery codes", + description: + "Every current recovery code will stop working and a new one-time set will be shown.", + title: "Rotate recovery codes?", + }; + } + } +} + +interface MfaManagementSectionProps { + readonly summary: AccountSecuritySummary; +} + +/** + * Coordinates possession-factor and recovery controls with one exclusive action boundary. + * @returns The MFA management section. + */ +export function MfaManagementSection({ summary }: MfaManagementSectionProps) { + const action = useExclusiveDashboardAction(); + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const [confirmation, setConfirmation] = useState(); + const [recoveryCodes, setRecoveryCodes] = useState(); + const [totpEnrollment, setTotpEnrollment] = useState(); + + async function refreshAfter(operation: () => Promise): Promise { + const result = await action.run(async () => { + await operation(); + await refreshSecurityQueries(queryClient); + }); + return result.status === "success"; + } + + async function disableMfa(password: string): Promise { + const succeeded = await refreshAfter(() => + client.mutation("accountSecurity.disableMfa", { password }) + ); + if (succeeded) { + setRecoveryCodes(undefined); + setTotpEnrollment(undefined); + } + return succeeded; + } + + async function rotateRecoveryCodes() { + await refreshAfter(async () => { + const result = await client.mutation( + "accountSecurity.rotateRecoveryCodes", + {} + ); + setRecoveryCodes(result.recoveryCodes); + }); + } + + async function removeTotp(factorId: string) { + await refreshAfter(() => + client.mutation("accountSecurity.removeTotpFactor", { factorId }) + ); + } + + async function removeWebAuthn(credentialId: string) { + await refreshAfter(() => + client.mutation("accountSecurity.removeWebAuthnCredential", { + credentialId, + }) + ); + } + + async function confirmMfaAction() { + const pendingConfirmation = confirmation; + if (pendingConfirmation === undefined) return; + try { + switch (pendingConfirmation.kind) { + case "remove-totp": { + await removeTotp(pendingConfirmation.factorId); + break; + } + case "remove-webauthn": { + await removeWebAuthn(pendingConfirmation.credentialId); + break; + } + case "rotate-recovery-codes": { + await rotateRecoveryCodes(); + break; + } + } + } finally { + setConfirmation(undefined); + } + } + + const confirmationCopy = + confirmation === undefined ? undefined : mfaConfirmationCopy(confirmation); + const factorCount = + summary.mfa.totpFactors.length + summary.mfa.webAuthnCredentials.length; + const factorCapacityReached = factorCount >= 4; + + return ( + + +

    + Status: {summary.mfa.enabled ? "Enabled" : "Disabled"} · {factorCount} of + 4 possession factors +

    + {recoveryCodes !== undefined && ( + setRecoveryCodes(undefined)} + title="New recovery codes" + > +
      + {recoveryCodes.map((code) => ( +
    • {code}
    • + ))} +
    +
    + )} +
    + + setConfirmation({ + factorId: factor.id, + kind: "remove-totp", + label: factor.label, + }) + } + refreshAfter={refreshAfter} + /> + + setConfirmation({ + credentialId: credential.id, + kind: "remove-webauthn", + label: credential.label, + }) + } + refreshAfter={refreshAfter} + /> +
    + {summary.mfa.enabled && ( + + setConfirmation({ kind: "rotate-recovery-codes" }) + } + /> + )} + setConfirmation(undefined)} + onConfirm={() => void confirmMfaAction()} + open={confirmation !== undefined} + title={confirmationCopy?.title ?? "Confirm MFA action"} + /> +
    + ); +} diff --git a/greenfield/src/browser/security/MfaRecoveryControls.tsx b/greenfield/src/browser/security/MfaRecoveryControls.tsx new file mode 100644 index 000000000..e7a30d7de --- /dev/null +++ b/greenfield/src/browser/security/MfaRecoveryControls.tsx @@ -0,0 +1,95 @@ +import { useForm } from "@tanstack/react-form"; +import { RefreshCw, ShieldOff } from "lucide-react"; + +import { disableMfaInputSchema } from "../../contracts/accountSecurity.ts"; +import type { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; + +interface MfaRecoveryControlsProps { + readonly action: ReturnType; + readonly onDisable: (password: string) => Promise; + readonly onRequestRecoveryCodeRotation: () => void; +} + +/** + * Renders recovery-code rotation and password-confirmed MFA disable controls. + * @returns Consequential MFA maintenance controls. + */ +export function MfaRecoveryControls({ + action, + onDisable, + onRequestRecoveryCodeRotation, +}: MfaRecoveryControlsProps) { + const disableForm = useForm({ + defaultValues: { password: "" }, + onSubmit: async ({ formApi, value }) => { + if (await onDisable(value.password)) { + formApi.setFieldValue("password", ""); + } + }, + validators: { onSubmit: disableMfaInputSchema }, + }); + + return ( +
    + +
    void disableForm.handleSubmit()} + > + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + type="password" + value={field.state.value} + /> + + )} + + [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    +
    + ); +} diff --git a/greenfield/src/browser/security/PasswordChangeForm.tsx b/greenfield/src/browser/security/PasswordChangeForm.tsx new file mode 100644 index 000000000..8df85a116 --- /dev/null +++ b/greenfield/src/browser/security/PasswordChangeForm.tsx @@ -0,0 +1,111 @@ +import { useForm } from "@tanstack/react-form"; +import { ShieldCheck } from "lucide-react"; + +import { passwordChangeInputSchema } from "../../contracts/auth.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import type { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; + +interface PasswordChangeFormProps { + readonly action: ReturnType; + readonly complete: ( + operation: () => Promise, + successMessage: string + ) => Promise; +} + +/** + * Rotates the Dashboard password and clears both proof fields after success. + * @returns The password-change form. + */ +export function PasswordChangeForm({ action, complete }: PasswordChangeFormProps) { + const client = useDashboardTrpcClient(); + const form = useForm({ + defaultValues: { currentPassword: "", newPassword: "" }, + onSubmit: async ({ formApi, value }) => { + await complete(async () => { + await client.mutation("auth.changePassword", value); + formApi.setFieldValue("currentPassword", ""); + formApi.setFieldValue("newPassword", ""); + }, "Password changed and other sessions revoked."); + }, + validators: { onSubmit: passwordChangeInputSchema }, + }); + + return ( +
    void form.handleSubmit()} + > + Change password +
    + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + type="password" + value={field.state.value} + /> + + )} + + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + type="password" + value={field.state.value} + /> + + )} + +
    + [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + ); +} diff --git a/greenfield/src/browser/security/SecurityAuditSection.tsx b/greenfield/src/browser/security/SecurityAuditSection.tsx new file mode 100644 index 000000000..605c41525 --- /dev/null +++ b/greenfield/src/browser/security/SecurityAuditSection.tsx @@ -0,0 +1,236 @@ +import { useInfiniteQuery, infiniteQueryOptions } from "@tanstack/react-query"; +import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table"; +import { RefreshCw, ScrollText } from "lucide-react"; + +import type { + ListSecurityAuditEventsInput, + ListSecurityAuditEventsResult, + SecurityAuditEventSummary, +} from "../../contracts/securityAudit.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { DataTable } from "../ui/DataTable.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { Virtualizer } from "../ui/Virtualizer.tsx"; +import { securityAuditQueryKey } from "./securityQueries.ts"; +import { SecuritySection } from "./SecurityUi.tsx"; + +type SecurityAuditCursor = NonNullable; + +const emptyAuditEvents: readonly SecurityAuditEventSummary[] = Object.freeze([]); +const minimumVirtualizedAuditRows = 50; + +const auditTableFeatures = tableFeatures({}); +const auditColumnHelper = createColumnHelper< + typeof auditTableFeatures, + SecurityAuditEventSummary +>(); + +function actorLabel(actor: SecurityAuditEventSummary["actor"]): string { + return `${actor.kind}:${actor.id}`; +} + +function targetLabel(target: SecurityAuditEventSummary["target"]): string { + return `${target.type}:${target.id}`; +} + +function metadataLabel(metadata: SecurityAuditEventSummary["metadata"]): string { + const entries = Object.entries(metadata); + if (entries.length === 0) return "No public metadata"; + return entries + .map( + ([name, value]) => + `${name}=${Array.isArray(value) ? value.join(",") : String(value)}` + ) + .join(" · "); +} + +function outcomeBadgeVariant( + outcome: SecurityAuditEventSummary["outcome"] +): "danger" | "default" | "success" | "warning" { + switch (outcome) { + case "accepted": + case "succeeded": { + return "success"; + } + case "denied": + case "failed": { + return "danger"; + } + case "attempted": { + return "warning"; + } + case "cancelled": { + return "default"; + } + } +} + +const auditColumns = auditColumnHelper.columns([ + auditColumnHelper.accessor("action", { + cell: ({ getValue, row }) => ( +
    +

    {getValue()}

    + + {row.original.outcome} + +
    + ), + header: "Event", + }), + auditColumnHelper.accessor((event) => actorLabel(event.actor), { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Actor", + id: "actor", + }), + auditColumnHelper.accessor((event) => targetLabel(event.target), { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Target", + id: "target", + }), + auditColumnHelper.accessor("occurredAtMs", { + cell: ({ getValue }) => ( + + ), + header: "Time", + }), + auditColumnHelper.accessor((event) => metadataLabel(event.metadata), { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Public metadata", + id: "metadata", + }), +]); + +interface SecurityAuditTableProps { + readonly events: readonly SecurityAuditEventSummary[]; +} + +function SecurityAuditTable({ events }: SecurityAuditTableProps) { + const table = useTable({ + columns: auditColumns, + data: events, + features: auditTableFeatures, + getRowId: (event) => event.id, + }); + const rows = table.getRowModel().rows; + + if (rows.length < minimumVirtualizedAuditRows) { + return ( + + ); + } + + return ( + + count={rows.length} + estimateSize={() => 86} + getItemKey={(index) => rows[index]?.id ?? `missing-audit-row-${index}`} + > + {(virtualization) => ( + + )} + + ); +} + +/** + * Renders the complete cursor-paginated, redacted security audit history. + * @returns The immutable audit section. + */ +export function SecurityAuditSection() { + const client = useDashboardTrpcClient(); + const events = useInfiniteQuery( + infiniteQueryOptions({ + initialPageParam: undefined as SecurityAuditCursor | undefined, + queryFn: ({ pageParam, signal }): Promise => + client.query( + "securityAudit.listEvents", + pageParam === undefined + ? { limit: 50 } + : { cursor: pageParam, limit: 50 }, + { signal } + ), + getNextPageParam: (lastPage) => lastPage.nextCursor, + queryKey: securityAuditQueryKey, + retry: false, + staleTime: 0, + }) + ); + const auditEvents = + events.data?.pages.flatMap((page) => page.events) ?? emptyAuditEvents; + + return ( + + {events.isPending && ( + + )} + {events.isError && ( +
    + + +
    + )} + {events.isSuccess && auditEvents.length === 0 && ( + + )} + {auditEvents.length > 0 && } + {events.hasNextPage && ( + + )} +
    + ); +} diff --git a/greenfield/src/browser/security/SecurityProofControls.tsx b/greenfield/src/browser/security/SecurityProofControls.tsx new file mode 100644 index 000000000..a4ea3003a --- /dev/null +++ b/greenfield/src/browser/security/SecurityProofControls.tsx @@ -0,0 +1,233 @@ +import { useForm } from "@tanstack/react-form"; +import { Fingerprint, KeyRound, LifeBuoy, Smartphone } from "lucide-react"; + +import { + passwordReauthenticationInputSchema, + recoveryStepUpInputSchema, + totpStepUpInputSchema, +} from "../../contracts/accountSecurity.ts"; +import type { MultiFactorAuthenticationMethod } from "../../contracts/security.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import type { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { useDashboardWebAuthnClient } from "./webauthn/webauthnContextValue.ts"; + +interface SecurityProofControlsProps { + readonly action: ReturnType; + readonly complete: ( + operation: () => Promise, + successMessage: string + ) => Promise; + readonly methods: readonly MultiFactorAuthenticationMethod[]; +} + +/** + * Renders the available password and MFA recent-verification controls. + * @returns Contract-validated proof forms for every enrolled method. + */ +export function SecurityProofControls({ + action, + complete, + methods, +}: SecurityProofControlsProps) { + const client = useDashboardTrpcClient(); + const webAuthn = useDashboardWebAuthnClient(); + const passwordProofForm = useForm({ + defaultValues: { password: "" }, + onSubmit: async ({ formApi, value }) => { + await complete(async () => { + await client.mutation("accountSecurity.reauthenticatePassword", value); + formApi.setFieldValue("password", ""); + }, "Recent password verification refreshed."); + }, + validators: { onSubmit: passwordReauthenticationInputSchema }, + }); + const totpForm = useForm({ + defaultValues: { code: "" }, + onSubmit: async ({ formApi, value }) => { + await complete(async () => { + await client.mutation("accountSecurity.stepUpTotp", value); + formApi.setFieldValue("code", ""); + }, "Recent MFA verification refreshed."); + }, + validators: { onSubmit: totpStepUpInputSchema }, + }); + const recoveryForm = useForm({ + defaultValues: { code: "" }, + onSubmit: async ({ formApi, value }) => { + await complete(async () => { + await client.mutation("accountSecurity.stepUpRecovery", value); + formApi.setFieldValue("code", ""); + }, "Recovery proof accepted and recent MFA refreshed."); + }, + validators: { onSubmit: recoveryStepUpInputSchema }, + }); + + async function stepUpWebAuthn() { + await complete(async () => { + const challenge = await client.mutation( + "accountSecurity.beginWebAuthnStepUp", + {} + ); + const response = await webAuthn.authenticate(challenge.options); + await client.mutation("accountSecurity.stepUpWebAuthn", { response }); + }, "Security-key verification refreshed recent MFA."); + } + + return ( +
    +
    void passwordProofForm.handleSubmit()}> + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + type="password" + value={field.state.value} + /> + + )} + + [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + {methods.includes("totp") && ( +
    void totpForm.handleSubmit()}> + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + value={field.state.value} + /> + + )} + + + [state.canSubmit, state.isSubmitting] as const + } + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + )} + {methods.includes("recovery") && ( +
    void recoveryForm.handleSubmit()}> + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + required + spellCheck={false} + type="password" + value={field.state.value} + /> + + )} + + + [state.canSubmit, state.isSubmitting] as const + } + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + )} + {methods.includes("webauthn") && ( +
    +

    + Verify with one enrolled roaming security key. +

    + +
    + )} +
    + ); +} diff --git a/greenfield/src/browser/security/SecurityUi.tsx b/greenfield/src/browser/security/SecurityUi.tsx new file mode 100644 index 000000000..a6784ac89 --- /dev/null +++ b/greenfield/src/browser/security/SecurityUi.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; + +import { Button } from "../ui/Button.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Text } from "../ui/Text.tsx"; + +interface SecuritySectionProps { + readonly children: ReactNode; + readonly description: string; + readonly id: string; + readonly title: string; +} + +export function SecuritySection({ + children, + description, + id, + title, +}: SecuritySectionProps) { + return ( + + + {title} + + + {description} + +
    {children}
    +
    + ); +} + +interface OneTimeSecretPanelProps { + readonly children: ReactNode; + readonly id: string; + readonly onDismiss: () => void; + readonly title: string; +} + +export function OneTimeSecretPanel({ + children, + id, + onDismiss, + title, +}: OneTimeSecretPanelProps) { + return ( +
    + + {title} + + + Store this now. It is held only in this component and cannot be shown + again. + +
    + {children} +
    + +
    + ); +} diff --git a/greenfield/src/browser/security/SecurityVerificationSection.tsx b/greenfield/src/browser/security/SecurityVerificationSection.tsx new file mode 100644 index 000000000..92ad6bbc1 --- /dev/null +++ b/greenfield/src/browser/security/SecurityVerificationSection.tsx @@ -0,0 +1,71 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { AccountSecuritySummary } from "../../contracts/accountSecurity.ts"; +import type { MultiFactorAuthenticationMethod } from "../../contracts/security.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { PasswordChangeForm } from "./PasswordChangeForm.tsx"; +import { SecurityProofControls } from "./SecurityProofControls.tsx"; +import { refreshSecurityQueries } from "./securityQueries.ts"; +import { SecuritySection } from "./SecurityUi.tsx"; + +interface SecurityVerificationSectionProps { + readonly summary: AccountSecuritySummary; +} + +/** + * Coordinates recent-auth proofs and password rotation without caching secrets. + * @returns The verification management section. + */ +export function SecurityVerificationSection({ + summary, +}: SecurityVerificationSectionProps) { + const action = useExclusiveDashboardAction(); + const queryClient = useQueryClient(); + const [notice, setNotice] = useState(); + + async function complete( + operation: () => Promise, + successMessage: string + ): Promise { + setNotice(undefined); + const result = await action.run(async () => { + await operation(); + await refreshSecurityQueries(queryClient); + }); + if (result.status === "success") setNotice(successMessage); + } + + const methods: readonly MultiFactorAuthenticationMethod[] = summary.mfa.methods; + return ( + + + +
    +
    +
    Recent password
    +
    + {summary.recentAuth.password.recent ? "Valid" : "Required"} +
    +
    +
    +
    Recent MFA
    +
    + {summary.recentAuth.mfa.recent ? "Valid" : "Required"} +
    +
    +
    + + +
    + ); +} diff --git a/greenfield/src/browser/security/SessionManagementSection.tsx b/greenfield/src/browser/security/SessionManagementSection.tsx new file mode 100644 index 000000000..81ea4abe7 --- /dev/null +++ b/greenfield/src/browser/security/SessionManagementSection.tsx @@ -0,0 +1,251 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { LogOut, MonitorX, RefreshCw, ShieldX, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { resetAuthenticatedBrowserCache } from "../auth/authQueries.ts"; +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { ConfirmModal } from "../ui/ConfirmModal.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { + browserSessionsQueryOptions, + refreshSecurityQueries, +} from "./securityQueries.ts"; +import { SecuritySection } from "./SecurityUi.tsx"; + +const anonymousAuthStatus: AuthStatus = Object.freeze({ state: "anonymous" }); + +type SessionConfirmation = + | Readonly<{ kind: "revoke-all" }> + | Readonly<{ kind: "revoke-others" }> + | Readonly<{ kind: "revoke-session"; sessionId: string }>; + +function sessionConfirmationCopy(confirmation: SessionConfirmation) { + switch (confirmation.kind) { + case "revoke-session": { + return { + confirmLabel: "Revoke session", + description: + "This browser session will be signed out and must authenticate again.", + title: "Revoke browser session?", + }; + } + case "revoke-others": { + return { + confirmLabel: "Revoke other sessions", + description: + "Every browser except this one will be signed out and must authenticate again.", + title: "Revoke every other session?", + }; + } + case "revoke-all": { + return { + confirmLabel: "Revoke every session", + description: + "Every browser session, including this one, will be signed out immediately.", + title: "Revoke every browser session?", + }; + } + } +} + +/** + * Renders current and historical browser-session controls. + * @returns The browser-session management section. + */ +export function SessionManagementSection() { + const action = useExclusiveDashboardAction(); + const client = useDashboardTrpcClient(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const sessions = useQuery(browserSessionsQueryOptions(client)); + const [confirmation, setConfirmation] = useState(); + + async function leaveAuthenticatedBrowser(operation: () => Promise) { + const result = await action.run(operation); + if (result.status !== "success") return; + resetAuthenticatedBrowserCache(queryClient, anonymousAuthStatus); + await navigate({ replace: true, to: "/login" }); + resetAuthenticatedBrowserCache(queryClient, anonymousAuthStatus); + } + + async function revokeSession(sessionId: string) { + const result = await action.run(() => + client.mutation("auth.revokeSession", { sessionId }) + ); + if (result.status === "success") await refreshSecurityQueries(queryClient); + } + + async function revokeOtherSessions() { + const result = await action.run(() => + client.mutation("auth.revokeOtherSessions", {}) + ); + if (result.status === "success") await refreshSecurityQueries(queryClient); + } + + async function confirmSessionAction() { + const pendingConfirmation = confirmation; + if (pendingConfirmation === undefined) return; + try { + switch (pendingConfirmation.kind) { + case "revoke-session": { + await revokeSession(pendingConfirmation.sessionId); + break; + } + case "revoke-others": { + await revokeOtherSessions(); + break; + } + case "revoke-all": { + await leaveAuthenticatedBrowser(() => + client.mutation("auth.revokeAllSessions", {}) + ); + break; + } + } + } finally { + setConfirmation(undefined); + } + } + + const confirmationCopy = + confirmation === undefined ? undefined : sessionConfirmationCopy(confirmation); + + return ( + + + {sessions.isPending && ( + + )} + {sessions.isError && ( +
    + + +
    + )} + {sessions.isSuccess && sessions.data.sessions.length === 0 && ( + + )} + {sessions.isSuccess && ( +
      + {sessions.data.sessions.map((session) => ( +
    • +
      +
      +

      + {session.isCurrent + ? "Current browser" + : "Browser session"} + {session.isCurrent && ( + + Current + + )} +

      +

      + {session.authMethod} · last active{" "} + {formatDashboardDateTime(session.lastSeenAtMs)} +

      + {session.userAgent !== undefined && ( +

      + {session.userAgent} +

      + )} +
      + {!session.isCurrent && ( + + )} +
      +
    • + ))} +
    + )} +
    + + + +
    + setConfirmation(undefined)} + onConfirm={() => void confirmSessionAction()} + open={confirmation !== undefined} + title={confirmationCopy?.title ?? "Confirm session action"} + /> +
    + ); +} diff --git a/greenfield/src/browser/security/TotpFactorManagement.tsx b/greenfield/src/browser/security/TotpFactorManagement.tsx new file mode 100644 index 000000000..3807b45e9 --- /dev/null +++ b/greenfield/src/browser/security/TotpFactorManagement.tsx @@ -0,0 +1,234 @@ +import { useForm } from "@tanstack/react-form"; +import { Smartphone, Trash2 } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; + +import { + totpStepUpInputSchema, + type AccountSecuritySummary, + type TotpEnrollment, +} from "../../contracts/accountSecurity.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import type { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { optionalFactorLabelFormSchema } from "./mfaFormSchemas.ts"; +import { OneTimeSecretPanel } from "./SecurityUi.tsx"; + +interface TotpFactorManagementProps { + readonly action: ReturnType; + readonly enrollment: TotpEnrollment | undefined; + readonly factorCapacityReached: boolean; + readonly factors: AccountSecuritySummary["mfa"]["totpFactors"]; + readonly onEnrollmentChange: (enrollment: TotpEnrollment | undefined) => void; + readonly onRecoveryCodes: (codes: readonly string[]) => void; + readonly onRemove: (factor: Readonly<{ id: string; label: string }>) => void; + readonly refreshAfter: (operation: () => Promise) => Promise; +} + +/** + * Manages authenticator-app factors and the ephemeral TOTP enrollment secret. + * @returns Authenticator inventory and enrollment controls. + */ +export function TotpFactorManagement({ + action, + enrollment, + factorCapacityReached, + factors, + onEnrollmentChange, + onRecoveryCodes, + onRemove, + refreshAfter, +}: TotpFactorManagementProps) { + const client = useDashboardTrpcClient(); + const labelForm = useForm({ + defaultValues: { label: "" }, + onSubmit: async ({ formApi, value }) => { + const result = await action.run(() => + client.mutation( + "accountSecurity.beginTotpEnrollment", + value.label.length === 0 ? {} : { label: value.label } + ) + ); + if (result.status === "success") { + onEnrollmentChange(result.value.enrollment); + formApi.setFieldValue("label", ""); + } + }, + validators: { onSubmit: optionalFactorLabelFormSchema }, + }); + const confirmationForm = useForm({ + defaultValues: { code: "" }, + onSubmit: async ({ formApi, value }) => { + if (enrollment === undefined) return; + const succeeded = await refreshAfter(async () => { + const result = await client.mutation( + "accountSecurity.confirmTotpEnrollment", + { code: value.code, factorId: enrollment.factorId } + ); + if (result.enabledNow) onRecoveryCodes(result.recoveryCodes); + }); + if (succeeded) { + formApi.setFieldValue("code", ""); + onEnrollmentChange(undefined); + } + }, + validators: { onSubmit: totpStepUpInputSchema }, + }); + + function dismissEnrollment() { + confirmationForm.setFieldValue("code", ""); + onEnrollmentChange(undefined); + } + + return ( +
    + Authenticator apps +
      + {factors.map((factor) => ( +
    • +

      {factor.label}

      +

      + Confirmed {formatDashboardDateTime(factor.confirmedAtMs)} +

      + +
    • + ))} +
    +
    void labelForm.handleSubmit()}> + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + placeholder="Phone authenticator" + value={field.state.value} + /> + + )} + + [state.canSubmit, state.isSubmitting] as const} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + {enrollment !== undefined && ( + +
    +
    + +
    +

    + {enrollment.secret} +

    +

    + {enrollment.otpauthUri} +

    +
    void confirmationForm.handleSubmit()} + > + + {(field) => ( + + + field.handleChange( + event.currentTarget.value + ) + } + required + value={field.state.value} + /> + + )} + + + [state.canSubmit, state.isSubmitting] as const + } + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    +
    +
    + )} +
    + ); +} diff --git a/greenfield/src/browser/security/WebAuthnFactorManagement.tsx b/greenfield/src/browser/security/WebAuthnFactorManagement.tsx new file mode 100644 index 000000000..1f8f5143f --- /dev/null +++ b/greenfield/src/browser/security/WebAuthnFactorManagement.tsx @@ -0,0 +1,142 @@ +import { useForm } from "@tanstack/react-form"; +import { Fingerprint, Trash2 } from "lucide-react"; + +import type { AccountSecuritySummary } from "../../contracts/accountSecurity.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import type { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Button } from "../ui/Button.tsx"; +import { Form } from "../ui/Form.tsx"; +import { firstFormFieldError } from "../ui/formErrors.ts"; +import { FormField } from "../ui/FormField.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { optionalFactorLabelFormSchema } from "./mfaFormSchemas.ts"; +import { useDashboardWebAuthnClient } from "./webauthn/webauthnContextValue.ts"; + +interface WebAuthnFactorManagementProps { + readonly action: ReturnType; + readonly available: boolean; + readonly credentials: AccountSecuritySummary["mfa"]["webAuthnCredentials"]; + readonly factorCapacityReached: boolean; + readonly onRecoveryCodes: (codes: readonly string[]) => void; + readonly onRemove: (credential: Readonly<{ id: string; label: string }>) => void; + readonly refreshAfter: (operation: () => Promise) => Promise; +} + +/** + * Manages WebAuthn security-key inventory and registration ceremonies. + * @returns Security-key inventory and enrollment controls. + */ +export function WebAuthnFactorManagement({ + action, + available, + credentials, + factorCapacityReached, + onRecoveryCodes, + onRemove, + refreshAfter, +}: WebAuthnFactorManagementProps) { + const client = useDashboardTrpcClient(); + const webAuthn = useDashboardWebAuthnClient(); + const labelForm = useForm({ + defaultValues: { label: "" }, + onSubmit: async ({ formApi, value }) => { + const succeeded = await refreshAfter(async () => { + const challenge = await client.mutation( + "accountSecurity.beginWebAuthnEnrollment", + {} + ); + const response = await webAuthn.register(challenge.options); + const result = await client.mutation( + "accountSecurity.confirmWebAuthnEnrollment", + value.label.length === 0 + ? { response } + : { label: value.label, response } + ); + if (result.enabledNow) onRecoveryCodes(result.recoveryCodes); + }); + if (succeeded) formApi.setFieldValue("label", ""); + }, + validators: { onSubmit: optionalFactorLabelFormSchema }, + }); + + return ( +
    + Security keys +
      + {credentials.map((credential) => ( +
    • +

      {credential.label}

      +

      + Added {formatDashboardDateTime(credential.createdAtMs)} · + {credential.usable ? " usable" : " unavailable"} +

      + +
    • + ))} +
    + {available ? ( +
    void labelForm.handleSubmit()}> + + {(field) => ( + + + field.handleChange(event.currentTarget.value) + } + placeholder="Primary security key" + value={field.state.value} + /> + + )} + + + [state.canSubmit, state.isSubmitting] as const + } + > + {([canSubmit, isSubmitting]) => ( + + )} + +
    + ) : ( +

    + WebAuthn is unavailable for this origin. +

    + )} +
    + ); +} diff --git a/greenfield/src/browser/security/issuedAutomationToken.test.ts b/greenfield/src/browser/security/issuedAutomationToken.test.ts new file mode 100644 index 000000000..8b0e5c9a8 --- /dev/null +++ b/greenfield/src/browser/security/issuedAutomationToken.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; + +import { revealIssuedAutomationToken } from "./issuedAutomationToken.ts"; + +describe("issued automation token", () => { + test("reveals the one-time token before awaiting cache refresh", async () => { + const refresh = Promise.withResolvers(); + const sequence: string[] = []; + const completion = revealIssuedAutomationToken( + "one-time-token", + (token) => sequence.push(`reveal:${token}`), + async () => { + sequence.push("refresh:start"); + await refresh.promise; + sequence.push("refresh:complete"); + } + ); + + expect(sequence).toEqual(["reveal:one-time-token", "refresh:start"]); + refresh.resolve(); + await completion; + expect(sequence).toEqual([ + "reveal:one-time-token", + "refresh:start", + "refresh:complete", + ]); + }); +}); diff --git a/greenfield/src/browser/security/issuedAutomationToken.ts b/greenfield/src/browser/security/issuedAutomationToken.ts new file mode 100644 index 000000000..109f19ffb --- /dev/null +++ b/greenfield/src/browser/security/issuedAutomationToken.ts @@ -0,0 +1,15 @@ +/** + * Reveals a validated automation token before any cache refresh can delay the + * one-time secret boundary. + * @param token One-time token returned by the validated browser client. + * @param revealToken Component-local secret-state writer. + * @param refreshQueries Non-secret cache refresh started after reveal. + */ +export async function revealIssuedAutomationToken( + token: string, + revealToken: (token: string) => void, + refreshQueries: () => Promise +): Promise { + revealToken(token); + await refreshQueries(); +} diff --git a/greenfield/src/browser/security/mfaFormSchemas.ts b/greenfield/src/browser/security/mfaFormSchemas.ts new file mode 100644 index 000000000..620312593 --- /dev/null +++ b/greenfield/src/browser/security/mfaFormSchemas.ts @@ -0,0 +1,7 @@ +import * as v from "valibot"; + +import { factorLabelSchema } from "../../contracts/accountSecurity.ts"; + +export const optionalFactorLabelFormSchema = v.strictObject({ + label: v.union([v.literal(""), factorLabelSchema]), +}); diff --git a/greenfield/src/browser/security/securityQueries.ts b/greenfield/src/browser/security/securityQueries.ts new file mode 100644 index 000000000..147de4fde --- /dev/null +++ b/greenfield/src/browser/security/securityQueries.ts @@ -0,0 +1,58 @@ +import { queryOptions, type QueryClient } from "@tanstack/react-query"; + +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; +import { authStatusQueryKey } from "../auth/authQueries.ts"; + +export const accountSecuritySummaryQueryKey = ["account-security", "summary"] as const; +export const browserSessionsQueryKey = ["auth", "sessions"] as const; +export const securityAuditQueryKey = ["security-audit", "events"] as const; +export const automationPrincipalsQueryKey = [ + "automation-security", + "principals", +] as const; + +/** + * @param principalId Stable automation principal identity. + * @returns Stable credential-query key for one automation principal. + */ +export function automationCredentialsQueryKey(principalId: string) { + return ["automation-security", "credentials", principalId] as const; +} + +/** @returns Non-secret account security inventory query options. */ +export function accountSecuritySummaryQueryOptions(client: DashboardTrpcClient) { + return queryOptions({ + queryFn: ({ signal }) => client.query("accountSecurity.summary", {}, { signal }), + queryKey: accountSecuritySummaryQueryKey, + retry: false, + staleTime: 0, + }); +} + +/** @returns Non-secret browser-session inventory query options. */ +export function browserSessionsQueryOptions(client: DashboardTrpcClient) { + return queryOptions({ + queryFn: ({ signal }) => client.query("auth.sessions", {}, { signal }), + queryKey: browserSessionsQueryKey, + retry: false, + staleTime: 0, + }); +} + +/** + * Refreshes the non-secret security views after a successful mutation. + * @param queryClient Browser-owned query cache. + * @returns Completion after active observers have refetched. + */ +export async function refreshSecurityQueries(queryClient: QueryClient): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: accountSecuritySummaryQueryKey }), + queryClient.invalidateQueries({ queryKey: authStatusQueryKey }), + queryClient.invalidateQueries({ queryKey: automationPrincipalsQueryKey }), + queryClient.invalidateQueries({ queryKey: browserSessionsQueryKey }), + queryClient.invalidateQueries({ queryKey: securityAuditQueryKey }), + queryClient.invalidateQueries({ + queryKey: ["automation-security", "credentials"], + }), + ]); +} diff --git a/greenfield/src/browser/security/webauthn/webauthnClient.test.ts b/greenfield/src/browser/security/webauthn/webauthnClient.test.ts new file mode 100644 index 000000000..4a6b8c5e3 --- /dev/null +++ b/greenfield/src/browser/security/webauthn/webauthnClient.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import type { + WebAuthnAuthenticationOptions, + WebAuthnAuthenticationResponse, + WebAuthnRegistrationOptions, + WebAuthnRegistrationResponse, +} from "../../../contracts/webauthn.ts"; +import { + createDashboardWebAuthnClient, + type DashboardWebAuthnCeremonyPort, +} from "./webauthnClient.ts"; + +const authenticationOptions = { + allowCredentials: [{ id: "AAAAAAAA", type: "public-key" }], + challenge: "A".repeat(32), + rpId: "localhost", + timeout: 60_000, + userVerification: "required", +} satisfies WebAuthnAuthenticationOptions; + +const authenticationResponse = { + authenticatorAttachment: "cross-platform", + clientExtensionResults: {}, + id: "AAAAAAAA", + rawId: "AAAAAAAA", + response: { + authenticatorData: "AAAA", + clientDataJSON: "AAAA", + signature: "AAAA", + }, + type: "public-key", +} satisfies WebAuthnAuthenticationResponse; + +const registrationOptions = { + attestation: "none", + authenticatorSelection: { + authenticatorAttachment: "cross-platform", + requireResidentKey: false, + residentKey: "discouraged", + userVerification: "required", + }, + challenge: "A".repeat(32), + excludeCredentials: [], + extensions: { credProps: true }, + hints: ["security-key"], + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + rp: { id: "localhost", name: "Mira Dashboard" }, + timeout: 60_000, + user: { + displayName: "Operator", + id: "A".repeat(16), + name: "operator", + }, +} satisfies WebAuthnRegistrationOptions; + +const registrationResponse = { + authenticatorAttachment: "cross-platform", + clientExtensionResults: { credProps: { rk: false } }, + id: "BBBBBBBB", + rawId: "BBBBBBBB", + response: { + attestationObject: "AAAA", + authenticatorData: "AAAA", + clientDataJSON: "AAAA", + publicKey: "AAAA", + publicKeyAlgorithm: -7, + transports: ["usb"], + }, + type: "public-key", +} satisfies WebAuthnRegistrationResponse; + +describe("Dashboard WebAuthn client", () => { + test("passes exact options to the browser port and validates both responses", async () => { + const calls: unknown[] = []; + const ceremonies: DashboardWebAuthnCeremonyPort = Object.freeze({ + beginAuthentication: (options: WebAuthnAuthenticationOptions) => { + calls.push(options); + return Promise.resolve(authenticationResponse); + }, + beginRegistration: (options: WebAuthnRegistrationOptions) => { + calls.push(options); + return Promise.resolve(registrationResponse); + }, + }); + const client = createDashboardWebAuthnClient(ceremonies); + + expect(await client.authenticate(authenticationOptions)).toEqual( + authenticationResponse + ); + expect(await client.register(registrationOptions)).toEqual(registrationResponse); + expect(calls).toEqual([authenticationOptions, registrationOptions]); + }); + + test("rejects a malformed authentication response at the browser boundary", () => { + const ceremonies: DashboardWebAuthnCeremonyPort = Object.freeze({ + beginAuthentication: () => + Promise.resolve({ + ...authenticationResponse, + rawId: "CCCCCCCC", + }), + beginRegistration: () => Promise.resolve(registrationResponse), + }); + + expect( + createDashboardWebAuthnClient(ceremonies).authenticate(authenticationOptions) + ).rejects.toBeInstanceOf(v.ValiError); + }); + + test("rejects a malformed registration response at the browser boundary", () => { + const ceremonies: DashboardWebAuthnCeremonyPort = Object.freeze({ + beginAuthentication: () => Promise.resolve(authenticationResponse), + beginRegistration: () => + Promise.resolve({ + ...registrationResponse, + response: { + ...registrationResponse.response, + publicKeyAlgorithm: -257, + }, + }), + }); + + expect( + createDashboardWebAuthnClient(ceremonies).register(registrationOptions) + ).rejects.toBeInstanceOf(v.ValiError); + }); +}); diff --git a/greenfield/src/browser/security/webauthn/webauthnClient.ts b/greenfield/src/browser/security/webauthn/webauthnClient.ts new file mode 100644 index 000000000..fe14ce9cc --- /dev/null +++ b/greenfield/src/browser/security/webauthn/webauthnClient.ts @@ -0,0 +1,56 @@ +import * as v from "valibot"; + +import { + type WebAuthnAuthenticationOptions, + type WebAuthnAuthenticationResponse, + webAuthnAuthenticationResponseSchema, + type WebAuthnRegistrationOptions, + type WebAuthnRegistrationResponse, + webAuthnRegistrationResponseSchema, +} from "../../../contracts/webauthn.ts"; + +/** Browser ceremony boundary kept injectable for deterministic security-flow tests. */ +export interface DashboardWebAuthnClient { + authenticate( + options: WebAuthnAuthenticationOptions + ): Promise; + register(options: WebAuthnRegistrationOptions): Promise; +} + +/** Low-level browser ceremony port used by the validated Dashboard adapter. */ +export interface DashboardWebAuthnCeremonyPort { + beginAuthentication(options: WebAuthnAuthenticationOptions): Promise; + beginRegistration(options: WebAuthnRegistrationOptions): Promise; +} + +const simpleWebAuthnCeremonyPort: DashboardWebAuthnCeremonyPort = Object.freeze({ + async beginAuthentication(options: WebAuthnAuthenticationOptions) { + const { startAuthentication } = await import("@simplewebauthn/browser"); + return startAuthentication({ optionsJSON: options }); + }, + async beginRegistration(options: WebAuthnRegistrationOptions) { + const { startRegistration } = await import("@simplewebauthn/browser"); + return startRegistration({ optionsJSON: options }); + }, +}); + +/** + * Creates the browser-owned WebAuthn adapter. + * Ceremony responses remain ephemeral and are returned only to the requesting mutation. + * @param ceremonies Low-level WebAuthn browser operations. + * @returns WebAuthn authentication and registration operations. + */ +export function createDashboardWebAuthnClient( + ceremonies: DashboardWebAuthnCeremonyPort = simpleWebAuthnCeremonyPort +): DashboardWebAuthnClient { + return Object.freeze({ + async authenticate(options: WebAuthnAuthenticationOptions) { + const response = await ceremonies.beginAuthentication(options); + return v.parse(webAuthnAuthenticationResponseSchema, response); + }, + async register(options: WebAuthnRegistrationOptions) { + const response = await ceremonies.beginRegistration(options); + return v.parse(webAuthnRegistrationResponseSchema, response); + }, + }); +} diff --git a/greenfield/src/browser/security/webauthn/webauthnContext.tsx b/greenfield/src/browser/security/webauthn/webauthnContext.tsx new file mode 100644 index 000000000..40b7c7e40 --- /dev/null +++ b/greenfield/src/browser/security/webauthn/webauthnContext.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +import type { DashboardWebAuthnClient } from "./webauthnClient.ts"; +import { dashboardWebAuthnContext as DashboardWebAuthnContext } from "./webauthnContextValue.ts"; + +/** WebAuthn provider dependencies. */ +export interface DashboardWebAuthnProviderProps { + readonly children: ReactNode; + readonly client: DashboardWebAuthnClient; +} + +/** + * Provides the single browser-owned WebAuthn adapter to security routes. + * @returns The provider boundary. + */ +export function DashboardWebAuthnProvider({ + children, + client, +}: DashboardWebAuthnProviderProps) { + return {children}; +} diff --git a/greenfield/src/browser/security/webauthn/webauthnContextValue.ts b/greenfield/src/browser/security/webauthn/webauthnContextValue.ts new file mode 100644 index 000000000..dfca8a5ea --- /dev/null +++ b/greenfield/src/browser/security/webauthn/webauthnContextValue.ts @@ -0,0 +1,20 @@ +import { createContext, use } from "react"; + +import type { DashboardWebAuthnClient } from "./webauthnClient.ts"; + +/** Internal context shared by the WebAuthn provider and typed consumer hook. */ +export const dashboardWebAuthnContext = createContext< + DashboardWebAuthnClient | undefined +>(undefined); + +/** + * Reads the browser-owned WebAuthn ceremony adapter. + * @returns The configured adapter. + */ +export function useDashboardWebAuthnClient(): DashboardWebAuthnClient { + const client = use(dashboardWebAuthnContext); + if (client === undefined) { + throw new TypeError("Dashboard WebAuthn provider is missing"); + } + return client; +} diff --git a/greenfield/src/browser/testSupport/frontendBuildFixture/index.html b/greenfield/src/browser/test/fixtures/frontendBuild/index.html similarity index 100% rename from greenfield/src/browser/testSupport/frontendBuildFixture/index.html rename to greenfield/src/browser/test/fixtures/frontendBuild/index.html diff --git a/greenfield/src/browser/testSupport/frontendBuildFixture/src/FixtureApp.tsx b/greenfield/src/browser/test/fixtures/frontendBuild/src/FixtureApp.tsx similarity index 100% rename from greenfield/src/browser/testSupport/frontendBuildFixture/src/FixtureApp.tsx rename to greenfield/src/browser/test/fixtures/frontendBuild/src/FixtureApp.tsx diff --git a/greenfield/src/browser/testSupport/frontendBuildFixture/src/LazyPanel.tsx b/greenfield/src/browser/test/fixtures/frontendBuild/src/LazyPanel.tsx similarity index 100% rename from greenfield/src/browser/testSupport/frontendBuildFixture/src/LazyPanel.tsx rename to greenfield/src/browser/test/fixtures/frontendBuild/src/LazyPanel.tsx diff --git a/greenfield/src/browser/testSupport/frontendBuildFixture/src/index.css b/greenfield/src/browser/test/fixtures/frontendBuild/src/index.css similarity index 100% rename from greenfield/src/browser/testSupport/frontendBuildFixture/src/index.css rename to greenfield/src/browser/test/fixtures/frontendBuild/src/index.css diff --git a/greenfield/src/browser/testSupport/frontendBuildFixture/src/main.tsx b/greenfield/src/browser/test/fixtures/frontendBuild/src/main.tsx similarity index 100% rename from greenfield/src/browser/testSupport/frontendBuildFixture/src/main.tsx rename to greenfield/src/browser/test/fixtures/frontendBuild/src/main.tsx diff --git a/greenfield/src/browser/test/setup.ts b/greenfield/src/browser/test/setup.ts new file mode 100644 index 000000000..d04f665cf --- /dev/null +++ b/greenfield/src/browser/test/setup.ts @@ -0,0 +1,54 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect } from "bun:test"; + +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; + +interface HappyDomAsyncTaskOwner { + readonly abort: () => Promise; +} + +GlobalRegistrator.register({ url: "https://dashboard.test/" }); + +const [matcherModule, animationMocks, testingLibrary] = await Promise.all([ + import("@testing-library/jest-dom/matchers"), + import("jsdom-testing-mocks"), + import("@testing-library/react"), +]); +const { default: _defaultMatchers, ...matchers } = matcherModule; +expect.extend(matchers); + +const { configMocks, mockAnimationsApi } = animationMocks; + +function runReactAct(trigger: () => void): void { + act(() => { + trigger(); + }); +} + +configMocks({ + act: runReactAct, + afterAll, + afterEach, + beforeAll, + beforeEach, +}); +mockAnimationsApi(); + +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + +const { cleanup } = testingLibrary; + +function isHappyDomAsyncTaskOwner(value: unknown): value is HappyDomAsyncTaskOwner { + return ( + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "abort") === "function" + ); +} + +afterEach(async () => { + cleanup(); + document.body.replaceChildren(); + const happyDom: unknown = Reflect.get(globalThis, "happyDOM"); + if (isHappyDomAsyncTaskOwner(happyDom)) await happyDom.abort(); +}); diff --git a/greenfield/src/browser/testSupport/browserTestEnvironment.ts b/greenfield/src/browser/testSupport/browserTestEnvironment.ts deleted file mode 100644 index a744bda81..000000000 --- a/greenfield/src/browser/testSupport/browserTestEnvironment.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -interface BrowserTestEnvironmentLease { - /** Releases this test module's ownership of the shared browser environment. */ - readonly release: () => Promise; -} - -let activeLeases = 0; -let ownsRegisteredEnvironment = false; -let previousActEnvironment: unknown; -let previouslyHadActEnvironment = false; -let environmentTransition: Promise = Promise.resolve(); - -async function serializeEnvironmentTransition( - transition: () => Result | Promise -): Promise { - const result = environmentTransition.then(transition, transition); - environmentTransition = result.then( - () => null, - () => null - ); - return result; -} - -/** - * Acquires a reference-counted Happy DOM environment for browser tests. - * @returns A lease that restores globals after the last local owner releases it. - */ -export async function acquireBrowserTestEnvironment(): Promise { - await serializeEnvironmentTransition(() => { - if (activeLeases === 0) { - ownsRegisteredEnvironment = globalThis.document === undefined; - if (ownsRegisteredEnvironment) { - GlobalRegistrator.register({ url: "https://dashboard.test/" }); - } - previouslyHadActEnvironment = Object.hasOwn( - globalThis, - "IS_REACT_ACT_ENVIRONMENT" - ); - previousActEnvironment = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT"); - Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); - } - activeLeases += 1; - }); - let released = false; - - return { - release: async () => { - if (released) return; - released = true; - await serializeEnvironmentTransition(async () => { - activeLeases -= 1; - if (activeLeases !== 0) return; - - if (previouslyHadActEnvironment) { - Reflect.set( - globalThis, - "IS_REACT_ACT_ENVIRONMENT", - previousActEnvironment - ); - } else { - Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); - } - if (ownsRegisteredEnvironment) await GlobalRegistrator.unregister(); - ownsRegisteredEnvironment = false; - previousActEnvironment = undefined; - previouslyHadActEnvironment = false; - }); - }, - }; -} diff --git a/greenfield/src/browser/testSupport/browserTestPreload.ts b/greenfield/src/browser/testSupport/browserTestPreload.ts deleted file mode 100644 index b581f55b2..000000000 --- a/greenfield/src/browser/testSupport/browserTestPreload.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { acquireBrowserTestEnvironment } from "./browserTestEnvironment.ts"; - -await acquireBrowserTestEnvironment(); diff --git a/greenfield/src/browser/ui/ActionLink.tsx b/greenfield/src/browser/ui/ActionLink.tsx new file mode 100644 index 000000000..6ed3fc484 --- /dev/null +++ b/greenfield/src/browser/ui/ActionLink.tsx @@ -0,0 +1,35 @@ +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +import { buttonClassNames, type ButtonSize, type ButtonVariant } from "./buttonStyles.ts"; + +interface ActionLinkProps { + readonly children: ReactNode; + readonly className?: string; + readonly fullWidth?: boolean; + readonly size?: ButtonSize; + readonly to: "/" | "/account-security" | "/login"; + readonly variant?: ButtonVariant; +} + +/** + * Renders a semantic router link with the shared Dashboard action styling. + * @returns A client-side navigation action. + */ +export function ActionLink({ + children, + className, + fullWidth, + size, + to, + variant, +}: ActionLinkProps) { + return ( + + {children} + + ); +} diff --git a/greenfield/src/browser/ui/Alert.tsx b/greenfield/src/browser/ui/Alert.tsx new file mode 100644 index 000000000..50e33be5e --- /dev/null +++ b/greenfield/src/browser/ui/Alert.tsx @@ -0,0 +1,59 @@ +import { CircleAlert, CircleCheck, Info } from "lucide-react"; +import { useEffect, useRef } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { Icon } from "./Icon.tsx"; + +const alertStyle = Object.freeze({ + error: { + container: "border-red-900/70 bg-red-950/50 text-red-200", + icon: CircleAlert, + }, + info: { + container: "border-accent-900/70 bg-accent-950/45 text-accent-100", + icon: Info, + }, + success: { + container: "border-emerald-900/70 bg-emerald-950/40 text-emerald-200", + icon: CircleCheck, + }, +}); + +interface AlertProps { + readonly className?: string; + readonly message: string | undefined; + readonly variant?: keyof typeof alertStyle; +} + +/** + * Renders and focuses important asynchronous feedback. + * @returns An alert/status region, or nothing when no message exists. + */ +export function Alert({ className, message, variant = "error" }: AlertProps) { + const element = useRef(null); + useEffect(() => { + if (message !== undefined && variant === "error") element.current?.focus(); + }, [message, variant]); + if (message === undefined) return null; + const style = alertStyle[variant]; + return ( +
    + + {message} +
    + ); +} diff --git a/greenfield/src/browser/ui/AppErrorFallback.tsx b/greenfield/src/browser/ui/AppErrorFallback.tsx new file mode 100644 index 000000000..d8754cf15 --- /dev/null +++ b/greenfield/src/browser/ui/AppErrorFallback.tsx @@ -0,0 +1,20 @@ +import type { FallbackProps } from "react-error-boundary"; + +import { PageState } from "./PageState.tsx"; + +/** + * Renders the redacted application-level React error fallback. + * @returns A recoverable full-page error state without private exception details. + */ +export function AppErrorFallback({ resetErrorBoundary }: FallbackProps) { + return ( +
    + +
    + ); +} diff --git a/greenfield/src/browser/ui/Badge.tsx b/greenfield/src/browser/ui/Badge.tsx new file mode 100644 index 000000000..0d7f52d72 --- /dev/null +++ b/greenfield/src/browser/ui/Badge.tsx @@ -0,0 +1,40 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; + +const badgeStyles = Object.freeze({ + danger: "border-red-500/30 bg-red-500/15 text-red-300", + default: "border-primary-500/30 bg-primary-500/15 text-primary-300", + info: "border-accent-500/30 bg-accent-500/15 text-accent-300", + success: "border-emerald-500/30 bg-emerald-500/15 text-emerald-300", + warning: "border-amber-500/30 bg-amber-500/15 text-amber-300", +}); + +interface BadgeProps extends HTMLAttributes { + readonly children: ReactNode; + readonly variant?: keyof typeof badgeStyles; +} + +/** + * Renders compact shared status metadata. + * @returns A consistently styled status badge. + */ +export function Badge({ + children, + className, + variant = "default", + ...properties +}: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/greenfield/src/browser/ui/Button.tsx b/greenfield/src/browser/ui/Button.tsx new file mode 100644 index 000000000..a54f1fba4 --- /dev/null +++ b/greenfield/src/browser/ui/Button.tsx @@ -0,0 +1,60 @@ +import { Button as HeadlessButton } from "@headlessui/react"; +import { LoaderCircle } from "lucide-react"; +import type { ButtonHTMLAttributes, ReactNode, Ref } from "react"; + +import { buttonClassNames, type ButtonSize, type ButtonVariant } from "./buttonStyles.ts"; +import { Icon } from "./Icon.tsx"; + +export interface ButtonProps extends Omit< + ButtonHTMLAttributes, + "disabled" +> { + readonly busy?: boolean; + readonly busyLabel?: string; + readonly children: ReactNode; + readonly disabled?: boolean; + readonly fullWidth?: boolean; + readonly ref?: Ref; + readonly size?: ButtonSize; + readonly variant?: ButtonVariant; +} + +/** + * Renders the shared Headless UI-backed Dashboard button. + * @returns An accessible button with consistent interaction states. + */ +export function Button({ + busy = false, + busyLabel = "Working…", + children, + className, + disabled = false, + fullWidth = false, + ref, + size = "md", + type = "button", + variant = "primary", + ...properties +}: ButtonProps) { + const unavailable = busy || disabled; + return ( + + {busy && ( + + )} + {busy ? busyLabel : children} + + ); +} diff --git a/greenfield/src/browser/ui/Card.tsx b/greenfield/src/browser/ui/Card.tsx new file mode 100644 index 000000000..2e30d63a1 --- /dev/null +++ b/greenfield/src/browser/ui/Card.tsx @@ -0,0 +1,25 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; + +interface CardProps extends HTMLAttributes { + readonly children: ReactNode; +} + +/** + * Renders a shared Dashboard content card. + * @returns The styled card region. + */ +export function Card({ children, className, ...properties }: CardProps) { + return ( +
    + {children} +
    + ); +} diff --git a/greenfield/src/browser/ui/Checkbox.tsx b/greenfield/src/browser/ui/Checkbox.tsx new file mode 100644 index 000000000..3779583c8 --- /dev/null +++ b/greenfield/src/browser/ui/Checkbox.tsx @@ -0,0 +1,66 @@ +import { + Checkbox as HeadlessCheckbox, + Description, + Field, + Label, +} from "@headlessui/react"; +import { Check } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { Icon } from "./Icon.tsx"; + +interface CheckboxProps { + readonly checked: boolean; + readonly className?: string; + readonly description?: ReactNode; + readonly disabled?: boolean; + readonly label: ReactNode; + readonly onChange: (checked: boolean) => void; +} + +/** + * Renders one labelled Headless UI checkbox. + * @returns An accessible controlled checkbox with shared interaction states. + */ +export function Checkbox({ + checked, + className, + description, + disabled, + label, + onChange, +}: CheckboxProps) { + return ( + + + + +
    + + {description !== undefined && ( + + {description} + + )} +
    +
    + ); +} diff --git a/greenfield/src/browser/ui/ConfirmModal.tsx b/greenfield/src/browser/ui/ConfirmModal.tsx new file mode 100644 index 000000000..6319226a7 --- /dev/null +++ b/greenfield/src/browser/ui/ConfirmModal.tsx @@ -0,0 +1,61 @@ +import { TriangleAlert, X } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Button } from "./Button.tsx"; +import { Icon } from "./Icon.tsx"; +import { Modal } from "./Modal.tsx"; + +interface ConfirmModalProps { + readonly busy?: boolean; + readonly confirmLabel?: string; + readonly danger?: boolean; + readonly description: ReactNode; + readonly onCancel: () => void; + readonly onConfirm: () => void; + readonly open: boolean; + readonly title: ReactNode; +} + +/** + * Renders an explicit confirmation boundary for consequential actions. + * @returns A managed dialog with safe cancel and confirm actions. + */ +export function ConfirmModal({ + busy = false, + confirmLabel = "Confirm", + danger = false, + description, + onCancel, + onConfirm, + open, + title, +}: ConfirmModalProps) { + return ( + +
    + {danger && ( + + )} +

    {description}

    +
    +
    + + +
    +
    + ); +} diff --git a/greenfield/src/browser/ui/DataTable.test.tsx b/greenfield/src/browser/ui/DataTable.test.tsx new file mode 100644 index 000000000..2cfecac8c --- /dev/null +++ b/greenfield/src/browser/ui/DataTable.test.tsx @@ -0,0 +1,166 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table"; + +import { DataTable } from "./DataTable.tsx"; +import { Virtualizer } from "./Virtualizer.tsx"; + +const { render, screen } = await import("@testing-library/react"); + +const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetHeight" +); +const originalOffsetWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetWidth" +); +const hadOwnResizeObserver = Object.hasOwn(globalThis, "ResizeObserver"); +const originalResizeObserver = Reflect.get(globalThis, "ResizeObserver"); + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => 480, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 960, + }); + Reflect.set(globalThis, "ResizeObserver", undefined); +}); + +afterAll(() => { + if (originalOffsetHeight === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight"); + } else { + Object.defineProperty( + HTMLElement.prototype, + "offsetHeight", + originalOffsetHeight + ); + } + if (originalOffsetWidth === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth"); + } else { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + } + if (hadOwnResizeObserver) { + Reflect.set(globalThis, "ResizeObserver", originalResizeObserver); + } else { + Reflect.deleteProperty(globalThis, "ResizeObserver"); + } +}); + +interface FixtureRow { + readonly id: string; + readonly label: string; +} + +const fixtureTableFeatures = tableFeatures({}); +const fixtureColumnHelper = createColumnHelper(); +const fixtureColumns = fixtureColumnHelper.columns([ + fixtureColumnHelper.accessor("label", { header: "Label" }), +]); +const staticRows = Object.freeze([ + { id: "one", label: "First row" }, + { id: "two", label: "Second row" }, +]); +const virtualRows = Object.freeze( + Array.from({ length: 100 }, (_, index) => ({ + id: `row-${index}`, + label: `Virtual row ${index}`, + })) +); + +interface TableFixtureProps { + readonly data: readonly FixtureRow[]; + readonly virtualized: boolean; +} + +function TableFixture({ data, virtualized }: TableFixtureProps) { + const table = useTable({ + columns: fixtureColumns, + data, + features: fixtureTableFeatures, + getRowId: (row) => row.id, + }); + const rows = table.getRowModel().rows; + + if (!virtualized) { + return ; + } + + return ( + + count={rows.length} + estimateSize={() => 36} + getItemKey={(index) => rows[index]?.id ?? `missing-${index}`} + > + {(virtualization) => ( + + )} + + ); +} + +describe("Dashboard data table and virtualizer", () => { + test("renders a complete TanStack table without virtualization", () => { + render(); + + expect(screen.getByText("First row")).toBeTruthy(); + expect(screen.getByText("Second row")).toBeTruthy(); + }); + + test("composes the table with a bounded virtual row window", () => { + render(); + + expect(screen.getByText("Virtual row 0")).toBeTruthy(); + expect(screen.queryByText("Virtual row 99")).toBeNull(); + const table = screen.getByRole("table"); + expect(table.querySelector("[style]")).toBeNull(); + expect(table.querySelector("td[height]")).toBeTruthy(); + }); + + test("virtualizes non-table content independently", () => { + render( + count={100} estimateSize={() => 36}> + {(virtualization) => ( +
    +
    + {virtualization.virtualItems.map((item) => ( +
    + Virtual item {item.index} +
    + ))} +
    +
    + )} + + ); + + expect(screen.getByText("Virtual item 0")).toBeTruthy(); + expect(screen.queryByText("Virtual item 99")).toBeNull(); + }); +}); diff --git a/greenfield/src/browser/ui/DataTable.tsx b/greenfield/src/browser/ui/DataTable.tsx new file mode 100644 index 000000000..a0c9cc8d3 --- /dev/null +++ b/greenfield/src/browser/ui/DataTable.tsx @@ -0,0 +1,139 @@ +import type { ReactTable, Row, RowData, TableFeatures } from "@tanstack/react-table"; +import type { RefObject } from "react"; + +import { cn } from "../lib/classNames.ts"; + +export interface DataTableRowWindow { + readonly measureElement: (node: HTMLTableRowElement | null) => void; + readonly totalSize: number; + readonly virtualItems: readonly Readonly<{ + end: number; + index: number; + start: number; + }>[]; +} + +interface DataTableBaseProps { + readonly label: string; + readonly scrollClassName?: string; + readonly scrollContainerRef?: RefObject; + readonly table: ReactTable; + readonly tableClassName?: string; +} + +type DataTableProps< + TFeatures extends TableFeatures, + TData extends RowData, +> = DataTableBaseProps & Readonly<{ rowWindow?: DataTableRowWindow }>; + +/** + * Renders one TanStack Table instance with shared Dashboard table semantics. + * It renders every row by default and accepts an optional external row window. + * @returns A styled table that is independent of sorting and virtualization policy. + */ +export function DataTable({ + label, + rowWindow, + scrollClassName, + scrollContainerRef, + table, + tableClassName, +}: DataTableProps) { + const headerGroups = table.getHeaderGroups(); + const rows = table.getRowModel().rows; + const visibleColumnCount = headerGroups.at(-1)?.headers.length ?? 1; + const firstVirtualItem = rowWindow?.virtualItems.at(0); + const lastVirtualItem = rowWindow?.virtualItems.at(-1); + const topSpacerHeight = firstVirtualItem?.start ?? 0; + const bottomSpacerHeight = + rowWindow === undefined + ? 0 + : Math.max(0, rowWindow.totalSize - (lastVirtualItem?.end ?? 0)); + + function renderRow( + row: Row, + virtualItem?: DataTableRowWindow["virtualItems"][number] + ) { + return ( + + {row.getAllCells().map((cell) => ( + + + + ))} + + ); + } + + return ( +
    + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {topSpacerHeight > 0 && ( + + + )} + {rowWindow === undefined + ? rows.map((row) => renderRow(row)) + : rowWindow.virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + return row === undefined + ? null + : renderRow(row, virtualItem); + })} + {bottomSpacerHeight > 0 && ( + + + )} + +
    + {!header.isPlaceholder && ( + + )} +
    +
    + ); +} diff --git a/greenfield/src/browser/ui/EmptyState.tsx b/greenfield/src/browser/ui/EmptyState.tsx new file mode 100644 index 000000000..2806fa91c --- /dev/null +++ b/greenfield/src/browser/ui/EmptyState.tsx @@ -0,0 +1,43 @@ +import { Inbox, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { Card } from "./Card.tsx"; +import { Heading } from "./Heading.tsx"; +import { Icon } from "./Icon.tsx"; +import { Text } from "./Text.tsx"; + +interface EmptyStateProps { + readonly action?: ReactNode; + readonly className?: string; + readonly description?: ReactNode; + readonly icon?: LucideIcon; + readonly title: ReactNode; +} + +/** + * Renders a shared no-results or no-content state. + * @returns A clear empty-state card with an optional next action. + */ +export function EmptyState({ + action, + className, + description, + icon = Inbox, + title, +}: EmptyStateProps) { + return ( + + + + {title} + + {description !== undefined && ( + + {description} + + )} + {action !== undefined &&
    {action}
    } +
    + ); +} diff --git a/greenfield/src/browser/ui/ExpandableCard.tsx b/greenfield/src/browser/ui/ExpandableCard.tsx new file mode 100644 index 000000000..0e8f1a797 --- /dev/null +++ b/greenfield/src/browser/ui/ExpandableCard.tsx @@ -0,0 +1,71 @@ +import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react"; +import { ChevronDown, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { Card } from "./Card.tsx"; +import { Icon } from "./Icon.tsx"; + +interface ExpandableCardProps { + readonly children: ReactNode | ((open: boolean) => ReactNode); + readonly className?: string; + readonly defaultOpen?: boolean; + readonly description?: ReactNode; + readonly icon?: LucideIcon; + readonly title: ReactNode; +} + +/** + * Renders a Headless UI disclosure inside a shared content card. + * @returns An accessible expandable card with keyboard-managed disclosure state. + */ +export function ExpandableCard({ + children, + className, + defaultOpen = false, + description, + icon, + title, +}: ExpandableCardProps) { + return ( + + + {({ open }) => ( + <> + + + {icon !== undefined && ( + + )} + + + {title} + + {description !== undefined && ( + + {description} + + )} + + + + + + {typeof children === "function" ? children(open) : children} + + + )} + + + ); +} diff --git a/greenfield/src/browser/ui/Form.tsx b/greenfield/src/browser/ui/Form.tsx new file mode 100644 index 000000000..dc9456ee7 --- /dev/null +++ b/greenfield/src/browser/ui/Form.tsx @@ -0,0 +1,34 @@ +import type { FormHTMLAttributes, ReactNode } from "react"; + +interface FormProps extends Omit< + FormHTMLAttributes, + "children" | "onSubmit" +> { + readonly children: ReactNode; + readonly onSubmit: () => void; +} + +/** + * Prevents native navigation and delegates submission without deprecated React event types. + * @returns A client-managed HTML form boundary. + */ +export function Form({ + children, + noValidate = true, + onSubmit, + ...properties +}: FormProps) { + return ( +
    { + event.preventDefault(); + event.stopPropagation(); + onSubmit(); + }} + > + {children} +
    + ); +} diff --git a/greenfield/src/browser/ui/FormField.tsx b/greenfield/src/browser/ui/FormField.tsx new file mode 100644 index 000000000..595efb5c3 --- /dev/null +++ b/greenfield/src/browser/ui/FormField.tsx @@ -0,0 +1,51 @@ +import { Description, Field, Label } from "@headlessui/react"; +import type { ReactNode } from "react"; + +import { FormFieldInvalidContext } from "./formFieldContext.ts"; + +export interface FormFieldProps { + readonly children: ReactNode; + readonly className?: string; + readonly description?: ReactNode; + readonly disabled?: boolean; + readonly error?: string; + readonly label: ReactNode; +} + +/** + * Associates one arbitrary Headless UI control with shared field metadata. + * @returns A labelled control and its associated descriptions. + */ +export function FormField({ + children, + className, + description, + disabled = false, + error, + label, +}: FormFieldProps) { + const invalid = error !== undefined; + + return ( + + + {description !== undefined && ( + + {description} + + )} + + {children} + + {error !== undefined && ( + {error} + )} + + ); +} diff --git a/greenfield/src/browser/ui/Heading.tsx b/greenfield/src/browser/ui/Heading.tsx new file mode 100644 index 000000000..532a86677 --- /dev/null +++ b/greenfield/src/browser/ui/Heading.tsx @@ -0,0 +1,63 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; + +const headingSizes = Object.freeze({ + page: "text-3xl tracking-tight sm:text-4xl", + panel: "text-2xl", + section: "text-xl", + subsection: "text-base", +}); + +const defaultHeadingSizes = Object.freeze({ + 1: "page", + 2: "section", + 3: "subsection", +} satisfies Readonly>); + +interface HeadingProps extends Omit, "children"> { + readonly children: ReactNode; + readonly level: keyof typeof defaultHeadingSizes; + readonly size?: keyof typeof headingSizes; +} + +/** + * Renders a semantic Dashboard heading with the shared visual hierarchy. + * @returns An h1, h2, or h3 matching its document-outline level. + */ +export function Heading({ + children, + className, + level, + size = defaultHeadingSizes[level], + ...properties +}: HeadingProps) { + const resolvedClassName = cn( + "text-primary-50 font-semibold", + headingSizes[size], + className + ); + switch (level) { + case 1: { + return ( +

    + {children} +

    + ); + } + case 2: { + return ( +

    + {children} +

    + ); + } + case 3: { + return ( +

    + {children} +

    + ); + } + } +} diff --git a/greenfield/src/browser/ui/Icon.tsx b/greenfield/src/browser/ui/Icon.tsx new file mode 100644 index 000000000..e023386f7 --- /dev/null +++ b/greenfield/src/browser/ui/Icon.tsx @@ -0,0 +1,51 @@ +import type { LucideIcon } from "lucide-react"; + +import { cn } from "../lib/classNames.ts"; + +const iconSizes = Object.freeze({ + lg: "size-6", + md: "size-5", + sm: "size-4", + xl: "size-8", +}); + +const iconTones = Object.freeze({ + accent: "text-accent-300", + danger: "text-red-300", + default: "text-primary-400", + inherit: "text-inherit", + success: "text-emerald-300", + warning: "text-amber-300", +}); + +interface IconProps { + readonly className?: string; + readonly icon: LucideIcon; + readonly label?: string; + readonly size?: keyof typeof iconSizes; + readonly strokeWidth?: number; + readonly tone?: keyof typeof iconTones; +} + +/** + * Renders a consistently sized Lucide icon with explicit accessibility semantics. + * @returns A decorative icon when unlabeled, or a named image when labelled. + */ +export function Icon({ + className, + icon: Glyph, + label, + size = "md", + strokeWidth, + tone = "default", +}: IconProps) { + return ( + + ); +} diff --git a/greenfield/src/browser/ui/IconOnlyButton.tsx b/greenfield/src/browser/ui/IconOnlyButton.tsx new file mode 100644 index 000000000..055fd3a01 --- /dev/null +++ b/greenfield/src/browser/ui/IconOnlyButton.tsx @@ -0,0 +1,30 @@ +import type { LucideIcon } from "lucide-react"; + +import { Button, type ButtonProps } from "./Button.tsx"; +import { Icon } from "./Icon.tsx"; + +interface IconOnlyButtonProps extends Omit< + ButtonProps, + "busy" | "busyLabel" | "children" +> { + readonly icon: LucideIcon; + readonly label: string; +} + +/** + * Renders an icon-only shared button with a mandatory accessible label. + * @returns A consistently sized labelled icon button. + */ +export function IconOnlyButton({ + icon, + label, + size = "sm", + title = label, + ...properties +}: IconOnlyButtonProps) { + return ( + + ); +} diff --git a/greenfield/src/browser/ui/Input.tsx b/greenfield/src/browser/ui/Input.tsx new file mode 100644 index 000000000..26ebbea0f --- /dev/null +++ b/greenfield/src/browser/ui/Input.tsx @@ -0,0 +1,38 @@ +import { Input as HeadlessInput } from "@headlessui/react"; +import type { InputHTMLAttributes, Ref } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { useFormFieldInvalid } from "./formFieldContext.ts"; + +export interface InputProps extends Omit< + InputHTMLAttributes, + "aria-invalid" | "disabled" +> { + readonly disabled?: boolean; + readonly invalid?: boolean; + readonly ref?: Ref; +} + +/** + * Renders the shared Headless UI-backed Dashboard input. + * @returns An input with consistent validation and interaction states. + */ +export function Input({ className, disabled, invalid, ref, ...properties }: InputProps) { + const inheritedInvalid = useFormFieldInvalid(); + const resolvedInvalid = invalid ?? inheritedInvalid; + + return ( + + ); +} diff --git a/greenfield/src/browser/ui/LoadingState.tsx b/greenfield/src/browser/ui/LoadingState.tsx new file mode 100644 index 000000000..cdc6e2008 --- /dev/null +++ b/greenfield/src/browser/ui/LoadingState.tsx @@ -0,0 +1,41 @@ +import { LoaderCircle } from "lucide-react"; + +import { cn } from "../lib/classNames.ts"; +import { Icon } from "./Icon.tsx"; + +const loadingStateSizes = Object.freeze({ + lg: { container: "min-h-64", icon: "size-8" }, + md: { container: "min-h-40", icon: "size-6" }, + sm: { container: "min-h-24", icon: "size-5" }, +}); + +interface LoadingStateProps { + readonly className?: string; + readonly label?: string; + readonly size?: keyof typeof loadingStateSizes; +} + +/** + * Renders a consistent live loading state. + * @returns A labelled busy status suitable for pages and sections. + */ +export function LoadingState({ + className, + label = "Loading…", + size = "md", +}: LoadingStateProps) { + const styles = loadingStateSizes[size]; + return ( + + + {label} + + ); +} diff --git a/greenfield/src/browser/ui/Modal.tsx b/greenfield/src/browser/ui/Modal.tsx new file mode 100644 index 000000000..6ef01047f --- /dev/null +++ b/greenfield/src/browser/ui/Modal.tsx @@ -0,0 +1,91 @@ +import { + Description, + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { X } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { IconOnlyButton } from "./IconOnlyButton.tsx"; + +const sizeClasses = Object.freeze({ + lg: "max-w-2xl", + md: "max-w-lg", + sm: "max-w-md", +}); + +interface ModalProps { + readonly children: ReactNode; + readonly description?: ReactNode; + readonly dismissible?: boolean; + readonly onClose: () => void; + readonly open: boolean; + readonly size?: keyof typeof sizeClasses; + readonly title: ReactNode; +} + +/** + * Renders a managed Headless UI dialog with project-standard focus and motion. + * @returns The modal dialog portal. + */ +export function Modal({ + children, + description, + dismissible = true, + onClose, + open, + size = "md", + title, +}: ModalProps) { + return ( + {}} + open={open} + > + +
    +
    + +
    +
    + + {title} + + {description !== undefined && ( + + {description} + + )} +
    + {dismissible && ( + + )} +
    +
    {children}
    +
    +
    +
    +
    + ); +} diff --git a/greenfield/src/browser/ui/NavigationLink.tsx b/greenfield/src/browser/ui/NavigationLink.tsx new file mode 100644 index 000000000..42335356a --- /dev/null +++ b/greenfield/src/browser/ui/NavigationLink.tsx @@ -0,0 +1,42 @@ +import { Link } from "@tanstack/react-router"; +import type { MouseEventHandler, ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; + +interface NavigationLinkProps { + readonly active: boolean; + readonly children: ReactNode; + readonly className?: string; + readonly onClick?: MouseEventHandler; + readonly to: "/" | "/account-security"; +} + +/** + * Renders a router-aware navigation link with the shared active-state treatment. + * @returns A semantic client-side navigation link. + */ +export function NavigationLink({ + active, + children, + className, + onClick, + to, +}: NavigationLinkProps) { + return ( + + {children} + + ); +} diff --git a/greenfield/src/browser/ui/PageHeader.tsx b/greenfield/src/browser/ui/PageHeader.tsx new file mode 100644 index 000000000..e2d16776a --- /dev/null +++ b/greenfield/src/browser/ui/PageHeader.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +import { Heading } from "./Heading.tsx"; +import { Text } from "./Text.tsx"; + +interface PageHeaderProps { + readonly description: ReactNode; + readonly eyebrow?: ReactNode; + readonly title: ReactNode; +} + +/** + * Renders the shared hierarchy for one Dashboard route heading. + * @returns The route heading and description. + */ +export function PageHeader({ description, eyebrow, title }: PageHeaderProps) { + return ( +
    + {eyebrow !== undefined && ( + + {eyebrow} + + )} + + {title} + + + {description} + +
    + ); +} diff --git a/greenfield/src/browser/ui/PageState.tsx b/greenfield/src/browser/ui/PageState.tsx new file mode 100644 index 000000000..0c0cfe6f1 --- /dev/null +++ b/greenfield/src/browser/ui/PageState.tsx @@ -0,0 +1,97 @@ +import { RotateCw, TriangleAlert, type LucideIcon } from "lucide-react"; +import { type ReactNode, useId } from "react"; + +import { Button } from "./Button.tsx"; +import { Card } from "./Card.tsx"; +import { EmptyState } from "./EmptyState.tsx"; +import { Heading } from "./Heading.tsx"; +import { Icon } from "./Icon.tsx"; +import { LoadingState } from "./LoadingState.tsx"; +import { Text } from "./Text.tsx"; + +type PageStateProps = + | Readonly<{ + children: ReactNode; + status: "ready"; + }> + | Readonly<{ + label?: string; + size?: "lg" | "md" | "sm"; + status: "loading"; + }> + | Readonly<{ + message: ReactNode; + onRetry?: () => void; + retryBusy?: boolean; + retryLabel?: string; + status: "error"; + title?: ReactNode; + }> + | Readonly<{ + action?: ReactNode; + description?: ReactNode; + icon?: LucideIcon; + status: "empty"; + title: ReactNode; + }>; + +/** + * Renders exactly one explicit page or section state. + * @returns Ready content or a standard loading, error, or empty presentation. + */ +export function PageState(properties: PageStateProps) { + const errorHeadingId = useId(); + switch (properties.status) { + case "ready": { + return <>{properties.children}; + } + case "loading": { + return ; + } + case "empty": { + return ( + + ); + } + case "error": { + return ( + +
    + +
    + + {properties.title ?? "Dashboard unavailable"} + + {properties.message} + {properties.onRetry !== undefined && ( + + )} +
    +
    +
    + ); + } + } +} diff --git a/greenfield/src/browser/ui/Text.tsx b/greenfield/src/browser/ui/Text.tsx new file mode 100644 index 000000000..7d9fd16b7 --- /dev/null +++ b/greenfield/src/browser/ui/Text.tsx @@ -0,0 +1,48 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; + +const textSizes = Object.freeze({ + lg: "text-base leading-7", + md: "text-sm leading-6", + sm: "text-xs leading-5", +}); + +const textTones = Object.freeze({ + accent: "text-accent-300", + danger: "text-red-300", + default: "text-primary-300", + inherit: "text-inherit", + muted: "text-primary-400", + success: "text-emerald-300", + warning: "text-amber-200", +}); + +interface TextProps extends Omit, "children"> { + readonly as?: "p" | "span"; + readonly children: ReactNode; + readonly size?: keyof typeof textSizes; + readonly tone?: keyof typeof textTones; +} + +/** + * Renders shared Dashboard body or supporting text without hiding list semantics. + * @returns A paragraph or inline span with the selected text treatment. + */ +export function Text({ + as: Component = "p", + children, + className, + size = "md", + tone = "default", + ...properties +}: TextProps) { + return ( + + {children} + + ); +} diff --git a/greenfield/src/browser/ui/Virtualizer.tsx b/greenfield/src/browser/ui/Virtualizer.tsx new file mode 100644 index 000000000..4bec71ee4 --- /dev/null +++ b/greenfield/src/browser/ui/Virtualizer.tsx @@ -0,0 +1,50 @@ +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; +import { type ReactNode, type RefObject, useRef } from "react"; + +const defaultInitialRect = Object.freeze({ height: 480, width: 960 }); + +export interface VirtualizerRenderState { + readonly measureElement: (node: TItemElement | null) => void; + readonly scrollContainerRef: RefObject; + readonly totalSize: number; + readonly virtualItems: readonly VirtualItem[]; +} + +interface VirtualizerProps { + readonly children: (state: VirtualizerRenderState) => ReactNode; + readonly count: number; + readonly estimateSize: (index: number) => number; + readonly getItemKey?: (index: number) => VirtualItem["key"]; + readonly initialRect?: Readonly<{ height: number; width: number }>; + readonly overscan?: number; +} + +/** + * Exposes a shared TanStack Virtual window without imposing list or table markup. + * @returns The caller's presentation composed with scroll and measurement state. + */ +export function Virtualizer({ + children, + count, + estimateSize, + getItemKey, + initialRect = defaultInitialRect, + overscan = 6, +}: VirtualizerProps) { + const scrollContainerRef = useRef(null); + const virtualizer = useVirtualizer({ + count, + estimateSize, + getItemKey, + getScrollElement: () => scrollContainerRef.current, + initialRect, + overscan, + }); + + return children({ + measureElement: virtualizer.measureElement, + scrollContainerRef, + totalSize: virtualizer.getTotalSize(), + virtualItems: virtualizer.getVirtualItems(), + }); +} diff --git a/greenfield/src/browser/ui/buttonStyles.ts b/greenfield/src/browser/ui/buttonStyles.ts new file mode 100644 index 000000000..4ef17508d --- /dev/null +++ b/greenfield/src/browser/ui/buttonStyles.ts @@ -0,0 +1,48 @@ +import { cn } from "../lib/classNames.ts"; + +export type ButtonVariant = "danger" | "ghost" | "primary" | "secondary"; +export type ButtonSize = "lg" | "md" | "sm"; + +const variantClasses: Readonly> = Object.freeze({ + danger: "bg-red-600 text-white data-hover:bg-red-500 data-active:bg-red-700 hover:bg-red-500 active:bg-red-700", + ghost: "bg-transparent text-primary-300 data-hover:bg-primary-800 data-hover:text-primary-50 data-active:bg-primary-700 hover:bg-primary-800 hover:text-primary-50 active:bg-primary-700", + primary: + "bg-accent-500 text-white data-hover:bg-accent-400 data-active:bg-accent-600 hover:bg-accent-400 active:bg-accent-600", + secondary: + "bg-primary-700 text-primary-100 data-hover:bg-primary-600 data-active:bg-primary-500 hover:bg-primary-600 active:bg-primary-500", +}); + +const sizeClasses: Readonly> = Object.freeze({ + lg: "min-h-12 px-5 py-3 text-base", + md: "min-h-10 px-4 py-2 text-sm", + sm: "min-h-8 px-2.5 py-1.5 text-sm", +}); + +interface ButtonStyleOptions { + readonly className?: string; + readonly fullWidth?: boolean; + readonly size?: ButtonSize; + readonly variant?: ButtonVariant; +} + +/** + * Produces the shared visual contract for buttons and action links. + * @param options Variant, size, and layout options. + * @returns Merged Dashboard action classes. + */ +export function buttonClassNames({ + className, + fullWidth = false, + size = "md", + variant = "primary", +}: ButtonStyleOptions = {}): string { + return cn( + "inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors", + "focus-visible:ring-accent-300 focus-visible:ring-offset-primary-900 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-55 data-disabled:cursor-not-allowed data-disabled:opacity-55", + variantClasses[variant], + sizeClasses[size], + fullWidth && "w-full", + className + ); +} diff --git a/greenfield/src/browser/ui/formErrors.ts b/greenfield/src/browser/ui/formErrors.ts new file mode 100644 index 000000000..5846c88b3 --- /dev/null +++ b/greenfield/src/browser/ui/formErrors.ts @@ -0,0 +1,24 @@ +/** + * Extracts one presentable error from TanStack Form or Standard Schema metadata. + * @param errors Unknown form-library error values. + * @returns The first safe human-readable error, when present. + */ +export function firstFormFieldError(errors: readonly unknown[]): string | undefined { + for (const error of errors) { + if (typeof error === "string" && error.length > 0) return error; + if (Array.isArray(error)) { + const nested = firstFormFieldError(error); + if (nested !== undefined) return nested; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.length > 0 + ) { + return error.message; + } + } + return undefined; +} diff --git a/greenfield/src/browser/ui/formFieldContext.ts b/greenfield/src/browser/ui/formFieldContext.ts new file mode 100644 index 000000000..a92f82394 --- /dev/null +++ b/greenfield/src/browser/ui/formFieldContext.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from "react"; + +export const FormFieldInvalidContext = createContext(false); + +/** + * Returns whether the nearest shared form field currently has a validation error. + * @returns The inherited invalid state for a shared form control. + */ +export function useFormFieldInvalid() { + return useContext(FormFieldInvalidContext); +} diff --git a/greenfield/src/contracts/accountSecurity.test.ts b/greenfield/src/contracts/accountSecurity.test.ts index a509d1818..f2b37765e 100644 --- a/greenfield/src/contracts/accountSecurity.test.ts +++ b/greenfield/src/contracts/accountSecurity.test.ts @@ -309,6 +309,16 @@ describe("account-security contracts", () => { "accountSecurity.rotateRecoveryCodes", "accountSecurity.disableMfa", ]); + for (const name of [ + "accountSecurity.stepUpWebAuthn", + "accountSecurity.confirmWebAuthnEnrollment", + ] as const) { + expect( + accountSecurityProcedureContracts.find( + (contract) => contract.name === name + )?.transport.requestBody + ).toBe("webauthn"); + } const enrollment = accountSecurityProcedureContracts.find( ({ name }) => name === "accountSecurity.beginTotpEnrollment" diff --git a/greenfield/src/contracts/accountSecurity.ts b/greenfield/src/contracts/accountSecurity.ts index 08c330ed1..9044e1b1f 100644 --- a/greenfield/src/contracts/accountSecurity.ts +++ b/greenfield/src/contracts/accountSecurity.ts @@ -421,6 +421,10 @@ const authenticationMutationTransport = { handler: "authentication", requestBody: "authentication", } as const; +const webAuthnAuthenticationMutationTransport = { + ...authenticationMutationTransport, + requestBody: "webauthn", +} as const; /** Implemented account-security procedure metadata. */ export const accountSecurityProcedureContracts = [ @@ -522,7 +526,7 @@ export const accountSecurityProcedureContracts = [ output: webAuthnStepUpResultSchema, outputSchemaId: "accountSecurity.stepUpWebAuthn.output", summary: "Consumes a WebAuthn challenge and rotates the verified session.", - transport: authenticationMutationTransport, + transport: webAuthnAuthenticationMutationTransport, }, { access: factorEnrollmentAccess, @@ -591,7 +595,7 @@ export const accountSecurityProcedureContracts = [ outputSchemaId: "accountSecurity.confirmWebAuthnEnrollment.output", summary: "Verifies and stores a WebAuthn credential, enabling MFA when it is first.", - transport: authenticationMutationTransport, + transport: webAuthnAuthenticationMutationTransport, }, { access: recentMfaAccess, diff --git a/greenfield/src/contracts/auth.test.ts b/greenfield/src/contracts/auth.test.ts index d16b82f0c..7980e761a 100644 --- a/greenfield/src/contracts/auth.test.ts +++ b/greenfield/src/contracts/auth.test.ts @@ -107,6 +107,11 @@ describe("authentication contracts", () => { authProcedureContracts.find((contract) => contract.name === name)?.access ).toEqual({ kind: "pending-login" }); } + expect( + authProcedureContracts.find( + (contract) => contract.name === "auth.loginWebAuthn" + )?.transport.requestBody + ).toBe("webauthn"); }); test("requires recent proof before revoking a browser session", () => { diff --git a/greenfield/src/contracts/auth.ts b/greenfield/src/contracts/auth.ts index 077650b59..359be6300 100644 --- a/greenfield/src/contracts/auth.ts +++ b/greenfield/src/contracts/auth.ts @@ -255,6 +255,10 @@ export const authSessionRevokeResultSchema = v.strictObject({ revoked: v.boolean(), }); +export const authSessionsRevokeResultSchema = v.strictObject({ + revokedSessions: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), +}); + export const passwordChangeResultSchema = v.strictObject({ revokedSessions: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), session: authSessionSummarySchema, @@ -290,6 +294,10 @@ const authenticationMutationTransport = { handler: "authentication", requestBody: "authentication", } as const; +const webAuthnAuthenticationMutationTransport = { + ...authenticationMutationTransport, + requestBody: "webauthn", +} as const; /** Implemented browser authentication procedure metadata. */ export const authProcedureContracts = [ @@ -386,7 +394,7 @@ export const authProcedureContracts = [ outputSchemaId: "auth.loginWebAuthn.output", summary: "Consumes a pending login and WebAuthn challenge to create the browser session.", - transport: authenticationMutationTransport, + transport: webAuthnAuthenticationMutationTransport, }, { access: publicAccess, @@ -442,6 +450,34 @@ export const authProcedureContracts = [ summary: "Revokes one browser session owned by the current user.", transport: authenticationMutationTransport, }, + { + access: sessionMutationAccess, + domain: "auth", + errorReasons: ["step_up_required"], + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: emptyInputSchema, + inputSchemaId: "auth.revokeOtherSessions.input", + kind: "mutation", + name: "auth.revokeOtherSessions", + output: authSessionsRevokeResultSchema, + outputSchemaId: "auth.revokeOtherSessions.output", + summary: "Revokes every browser session except the current session.", + transport: authenticationMutationTransport, + }, + { + access: sessionMutationAccess, + domain: "auth", + errorReasons: ["step_up_required"], + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: emptyInputSchema, + inputSchemaId: "auth.revokeAllSessions.input", + kind: "mutation", + name: "auth.revokeAllSessions", + output: authSessionsRevokeResultSchema, + outputSchemaId: "auth.revokeAllSessions.output", + summary: "Revokes every browser session, including the current session.", + transport: authenticationMutationTransport, + }, { access: passwordChangeAccess, domain: "auth", diff --git a/greenfield/src/contracts/contractRegistry.ts b/greenfield/src/contracts/contractRegistry.ts index c4a5d6e6c..e1bdda5c5 100644 --- a/greenfield/src/contracts/contractRegistry.ts +++ b/greenfield/src/contracts/contractRegistry.ts @@ -8,19 +8,25 @@ import { type RawHttpContract, type RealtimeEventContract, } from "./registry.ts"; +import { securityAuditProcedureContracts } from "./securityAudit.ts"; import { systemProcedureContracts, systemRawHttpContracts } from "./system.ts"; /** Implemented tRPC procedure metadata used by runtime wiring and docs. */ -const registeredProcedureContracts: readonly ProcedureContract[] = [ +const registeredProcedureContracts = [ ...accountSecurityProcedureContracts, ...authProcedureContracts, ...automationSecurityProcedureContracts, eventsStreamContract, + ...securityAuditProcedureContracts, ...systemProcedureContracts, -]; +] as const satisfies readonly ProcedureContract[]; + +/** Exact registered procedure union used by environment-neutral typed clients. */ +export type RegisteredProcedureContract = (typeof registeredProcedureContracts)[number]; assertProcedureContractErrors(registeredProcedureContracts); export const procedureContracts = Object.freeze( - registeredProcedureContracts.map((contract) => { + registeredProcedureContracts.map((registeredContract) => { + const contract: ProcedureContract = registeredContract; const access = "capabilities" in contract.access ? Object.freeze({ diff --git a/greenfield/src/contracts/registry.ts b/greenfield/src/contracts/registry.ts index 41dcb3880..bc18cf317 100644 --- a/greenfield/src/contracts/registry.ts +++ b/greenfield/src/contracts/registry.ts @@ -49,7 +49,7 @@ export interface ProcedureTransportContract { /** Bun idle-timeout profile used while the procedure is active. */ handler: "authentication" | "default" | "long-lived"; /** Raw request-body budget selected before authentication and parsing. */ - requestBody: "authentication" | "default"; + requestBody: "authentication" | "default" | "webauthn"; } /** Metadata for one controlled tRPC procedure. */ diff --git a/greenfield/src/contracts/securityAudit.test.ts b/greenfield/src/contracts/securityAudit.test.ts new file mode 100644 index 000000000..2c2ef515a --- /dev/null +++ b/greenfield/src/contracts/securityAudit.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { + listSecurityAuditEventsInputSchema, + listSecurityAuditEventsResultSchema, +} from "./securityAudit.ts"; + +const firstId = "019fc968-1a9b-7772-8f1b-d5b863b0e7b4"; +const secondId = "019fc968-1a9b-7771-8f1b-d5b863b0e7b4"; + +function event(id: string, occurredAtMs: number) { + return { + action: "auth.session.revoke-all", + actor: { + authenticatorId: "a".repeat(32), + id: "019fc968-1a9b-7770-8f1b-d5b863b0e7b4", + kind: "user" as const, + }, + id, + metadata: { revokedSessions: 2 }, + occurredAtMs, + outcome: "succeeded" as const, + requestId: "request-audit-list", + target: { + id: "019fc968-1a9b-7770-8f1b-d5b863b0e7b4", + type: "auth_sessions", + }, + }; +} + +describe("security audit contracts", () => { + test("defaults and bounds the newest-first list input", () => { + expect(v.parse(listSecurityAuditEventsInputSchema, {})).toEqual({ + limit: 20, + }); + expect( + v.safeParse(listSecurityAuditEventsInputSchema, { limit: 51 }).success + ).toBeFalse(); + expect( + v.safeParse(listSecurityAuditEventsInputSchema, { + extra: true, + limit: 1, + }).success + ).toBeFalse(); + }); + + test("requires stable event order and a cursor matching the last row", () => { + const first = event(firstId, 1_800_000_000_000); + const second = event(secondId, 1_800_000_000_000); + expect( + v.parse(listSecurityAuditEventsResultSchema, { + events: [first, second], + nextCursor: { + id: second.id, + occurredAtMs: second.occurredAtMs, + }, + }).events + ).toHaveLength(2); + expect( + v.safeParse(listSecurityAuditEventsResultSchema, { + events: [second, first], + }).success + ).toBeFalse(); + expect( + v.safeParse(listSecurityAuditEventsResultSchema, { + events: [first, second], + nextCursor: { + id: first.id, + occurredAtMs: first.occurredAtMs, + }, + }).success + ).toBeFalse(); + }); + + test("rejects actor mismatches and unclassified metadata", () => { + const valid = event(firstId, 1_800_000_000_000); + expect( + v.safeParse(listSecurityAuditEventsResultSchema, { + events: [ + { + ...valid, + actor: { id: valid.actor.id, kind: "user" }, + }, + ], + }).success + ).toBeFalse(); + expect( + v.safeParse(listSecurityAuditEventsResultSchema, { + events: [ + { + ...valid, + metadata: { credential: "must-not-escape" }, + }, + ], + }).success + ).toBeFalse(); + }); +}); diff --git a/greenfield/src/contracts/securityAudit.ts b/greenfield/src/contracts/securityAudit.ts new file mode 100644 index 000000000..b2de4ccee --- /dev/null +++ b/greenfield/src/contracts/securityAudit.ts @@ -0,0 +1,249 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { + boundedNonBlankTextSchema, + nonnegativeSafeIntegerSchema, +} from "../shared/validation.ts"; +import type { ProcedureContract } from "./registry.ts"; +import { + applicationCapabilityListSchema, + automationPrincipalIdSchema, + opaqueSelectorSchema, + securityRecordIdSchema, +} from "./security.ts"; + +/** Default number of immutable audit events returned to the operator. */ +export const securityAuditPageDefault = 20; + +/** Hard response-row budget for the security audit inventory. */ +export const securityAuditPageMaximum = 50; + +export const securityAuditReasonValues = [ + "gateway_unavailable", + "identity_changed", + "invalid_credentials", + "invalid_current_password", + "invalid_gateway", + "recovery_invalid", + "recovery_pending_invalid", + "totp_invalid", + "totp_pending_invalid", + "webauthn_configuration_mismatch", + "webauthn_invalid", + "webauthn_pending_invalid", +] as const; + +const securityAuditTimestampSchema = timestampMillisecondsSchema( + "Security audit timestamp is invalid" +); +const securityAuditNameSchema = v.pipe( + v.string("Security audit name is invalid"), + v.minLength(1, "Security audit name is invalid"), + v.maxLength(128, "Security audit name is invalid"), + v.regex(/^[a-z0-9][a-z0-9._-]*$/u, "Security audit name is invalid") +); +const securityAuditCountSchema = nonnegativeSafeIntegerSchema( + "Security audit count is invalid" +); + +export const securityAuditPageLimitSchema = v.pipe( + v.number("Security audit page limit is invalid"), + v.safeInteger("Security audit page limit is invalid"), + v.minValue(1, "Security audit page limit is invalid"), + v.maxValue( + securityAuditPageMaximum, + "Security audit page limit is outside its budget" + ) +); + +/** Stable newest-first cursor over immutable audit event time and identity. */ +export const securityAuditCursorSchema = v.strictObject({ + id: securityRecordIdSchema, + occurredAtMs: securityAuditTimestampSchema, +}); + +/** Explicitly redacted metadata fields permitted on public audit summaries. */ +export const securityAuditMetadataSchema = v.strictObject({ + addedCapabilities: v.optional(applicationCapabilityListSchema), + method: v.optional(v.picklist(["password", "recovery", "totp", "webauthn"])), + pendingMfa: v.optional(v.boolean()), + predecessorCredentialId: v.optional(securityRecordIdSchema), + reason: v.optional(v.picklist(securityAuditReasonValues)), + removedCapabilities: v.optional(applicationCapabilityListSchema), + replacementCredentialId: v.optional(securityRecordIdSchema), + revoked: v.optional(v.boolean()), + revokedCredentials: v.optional(securityAuditCountSchema), + revokedSessions: v.optional(securityAuditCountSchema), +}); + +const anonymousAuditActorSchema = v.strictObject({ + id: boundedNonBlankTextSchema(128, "Security audit actor is invalid"), + kind: v.literal("anonymous"), +}); +const automationAuditActorSchema = v.strictObject({ + authenticatorId: securityRecordIdSchema, + id: automationPrincipalIdSchema, + kind: v.literal("automation"), +}); +const systemAuditActorSchema = v.strictObject({ + id: boundedNonBlankTextSchema(128, "Security audit actor is invalid"), + kind: v.literal("system"), +}); +const userAuditActorSchema = v.strictObject({ + authenticatorId: opaqueSelectorSchema, + id: securityRecordIdSchema, + kind: v.literal("user"), +}); + +export const securityAuditActorSchema = v.variant("kind", [ + anonymousAuditActorSchema, + automationAuditActorSchema, + systemAuditActorSchema, + userAuditActorSchema, +]); + +export const securityAuditTargetSchema = v.strictObject({ + id: boundedNonBlankTextSchema(256, "Security audit target is invalid"), + type: v.pipe( + securityAuditNameSchema, + v.maxLength(64, "Security audit target is invalid") + ), +}); + +export const securityAuditEventSummarySchema = v.strictObject({ + action: securityAuditNameSchema, + actor: securityAuditActorSchema, + id: securityRecordIdSchema, + metadata: securityAuditMetadataSchema, + occurredAtMs: securityAuditTimestampSchema, + outcome: v.picklist([ + "accepted", + "attempted", + "cancelled", + "denied", + "failed", + "succeeded", + ]), + requestId: v.optional( + boundedNonBlankTextSchema(128, "Security audit request id is invalid") + ), + target: securityAuditTargetSchema, +}); + +type SecurityAuditEventSummaryValue = v.InferOutput< + typeof securityAuditEventSummarySchema +>; + +function eventFollowsInNewestFirstOrder( + previous: SecurityAuditEventSummaryValue, + current: SecurityAuditEventSummaryValue +): boolean { + return ( + current.occurredAtMs < previous.occurredAtMs || + (current.occurredAtMs === previous.occurredAtMs && current.id < previous.id) + ); +} + +export function securityAuditEventsHaveStableOrder( + events: SecurityAuditEventSummaryValue[] +): boolean { + for (let index = 1; index < events.length; index += 1) { + const previous = events[index - 1]; + const current = events[index]; + if ( + previous === undefined || + current === undefined || + !eventFollowsInNewestFirstOrder(previous, current) + ) { + return false; + } + } + return true; +} + +const securityAuditEventPageSchema = v.pipe( + v.array(securityAuditEventSummarySchema, "Security audit event page is invalid"), + v.maxLength( + securityAuditPageMaximum, + "Security audit event page is outside its budget" + ), + v.check( + securityAuditEventsHaveStableOrder, + "Security audit event page order is invalid" + ) +); + +export const listSecurityAuditEventsInputSchema = v.strictObject({ + cursor: v.optional(securityAuditCursorSchema), + limit: v.optional(securityAuditPageLimitSchema, securityAuditPageDefault), +}); + +const listSecurityAuditEventsResultObjectSchema = v.strictObject({ + events: securityAuditEventPageSchema, + nextCursor: v.optional(securityAuditCursorSchema), +}); + +type ListSecurityAuditEventsResultValue = v.InferOutput< + typeof listSecurityAuditEventsResultObjectSchema +>; + +export function securityAuditPageCursorIsConsistent( + result: ListSecurityAuditEventsResultValue +): boolean { + if (result.nextCursor === undefined) return true; + const last = result.events.at(-1); + return ( + last !== undefined && + result.nextCursor.occurredAtMs === last.occurredAtMs && + result.nextCursor.id === last.id + ); +} + +export const listSecurityAuditEventsResultSchema = v.pipe( + listSecurityAuditEventsResultObjectSchema, + v.check( + securityAuditPageCursorIsConsistent, + "Security audit continuation cursor is inconsistent" + ) +); + +const sessionAccess = { + capabilities: [], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["session"], +} as const; +const authenticationReadTransport = { + batching: "adapter-default", + handler: "authentication", + requestBody: "authentication", +} as const; + +/** Browser-session-only immutable security audit procedure metadata. */ +export const securityAuditProcedureContracts = [ + { + access: sessionAccess, + domain: "securityAudit", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: listSecurityAuditEventsInputSchema, + inputSchemaId: "securityAudit.listEvents.input", + kind: "query", + name: "securityAudit.listEvents", + output: listSecurityAuditEventsResultSchema, + outputSchemaId: "securityAudit.listEvents.output", + summary: "Lists redacted immutable security events in stable newest-first order.", + transport: authenticationReadTransport, + }, +] as const satisfies readonly ProcedureContract[]; + +export type ListSecurityAuditEventsInput = v.InferOutput< + typeof listSecurityAuditEventsInputSchema +>; +export type ListSecurityAuditEventsResult = v.InferOutput< + typeof listSecurityAuditEventsResultSchema +>; +export type SecurityAuditEventSummary = v.InferOutput< + typeof securityAuditEventSummarySchema +>; +export type SecurityAuditMetadata = v.InferOutput; diff --git a/greenfield/src/server/domains/security/audit.ts b/greenfield/src/server/domains/security/audit.ts index b6916777c..5f61f442a 100644 --- a/greenfield/src/server/domains/security/audit.ts +++ b/greenfield/src/server/domains/security/audit.ts @@ -5,22 +5,9 @@ import { securityRecordIdSchema, type ApplicationCapability, } from "../../../contracts/security.ts"; +import { securityAuditReasonValues } from "../../../contracts/securityAudit.ts"; import { auditEventInsertSchema } from "../../database/validation/auditEvents.ts"; -const securityAuditReasonValues = [ - "gateway_unavailable", - "identity_changed", - "invalid_credentials", - "invalid_current_password", - "invalid_gateway", - "recovery_invalid", - "recovery_pending_invalid", - "totp_invalid", - "totp_pending_invalid", - "webauthn_configuration_mismatch", - "webauthn_invalid", - "webauthn_pending_invalid", -] as const; type SecurityAuditReason = (typeof securityAuditReasonValues)[number]; const securityAuditReasons: ReadonlySet = new Set(securityAuditReasonValues); diff --git a/greenfield/src/server/domains/security/authSessionRoutes.ts b/greenfield/src/server/domains/security/authSessionRoutes.ts index 60f8857e3..ff7eb1249 100644 --- a/greenfield/src/server/domains/security/authSessionRoutes.ts +++ b/greenfield/src/server/domains/security/authSessionRoutes.ts @@ -4,12 +4,14 @@ import * as v from "valibot"; import { authSessionListSchema, authSessionRevokeResultSchema, + authSessionsRevokeResultSchema, authSessionTouchResultSchema, passwordChangeInputSchema, passwordChangeResultSchema, sessionRevokeInputSchema, } from "../../../contracts/auth.ts"; import { emptyInputSchema } from "../../../contracts/system.ts"; +import { appendClearedPendingLoginCookie } from "../../rawHttp/pendingLoginCookie.ts"; import { appendClearedDashboardSessionCookie, appendDashboardSessionCookie, @@ -97,6 +99,56 @@ export const authSessionRoutes = { } return result; }), + revokeAllSessions: sessionProcedure + .input(emptyInputSchema) + .output(authSessionsRevokeResultSchema) + .mutation(async ({ ctx }) => { + const result = await ctx.authenticationLifecycle.revokeAllSessions( + ctx.sessionIdentity, + authenticationRequestMetadata(ctx, undefined) + ); + if (result === undefined) { + appendClearedDashboardSessionCookie(ctx.responseHeaders); + appendClearedPendingLoginCookie(ctx.responseHeaders); + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication state changed; sign in again", + }); + } + if ("status" in result) { + throw authenticationPolicyError( + "step_up_required", + "Recent password or multi-factor authentication is required" + ); + } + const output = v.parse(authSessionsRevokeResultSchema, result); + appendClearedDashboardSessionCookie(ctx.responseHeaders); + appendClearedPendingLoginCookie(ctx.responseHeaders); + return output; + }), + revokeOtherSessions: sessionProcedure + .input(emptyInputSchema) + .output(authSessionsRevokeResultSchema) + .mutation(async ({ ctx }) => { + const result = await ctx.authenticationLifecycle.revokeOtherSessions( + ctx.sessionIdentity, + authenticationRequestMetadata(ctx, undefined) + ); + if (result === undefined) { + appendClearedDashboardSessionCookie(ctx.responseHeaders); + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication state changed; sign in again", + }); + } + if ("status" in result) { + throw authenticationPolicyError( + "step_up_required", + "Recent password or multi-factor authentication is required" + ); + } + return v.parse(authSessionsRevokeResultSchema, result); + }), sessions: sessionProcedure .input(emptyInputSchema) .output(authSessionListSchema) diff --git a/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts index 194790484..b0f4e4b1c 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts @@ -194,6 +194,182 @@ describe("authentication lifecycle sessions", () => { } }); + test("revokes other and all sessions atomically without no-op audit growth", async () => { + const harness = await createAuthenticationLifecycleHarness(); + + try { + const created = await bootstrapAuthenticationLifecycle(harness); + const identity = { + sessionId: created.session.id, + userId: created.user.id, + }; + for (const requestId of ["request-second", "request-third"]) { + const login = await harness.service.login( + { password: "current-password-1", username: "operator" }, + { clientSourceId: requestId, requestId } + ); + if (login.status !== "created") { + throw new Error(`Expected login creation, received ${login.status}`); + } + } + + expect( + await harness.service.revokeOtherSessions(identity, { + clientSourceId: "client-source-1", + requestId: "request-revoke-others", + }) + ).toEqual({ revokedSessions: 2 }); + expect( + harness.database.sqlite + .query<{ id: string }, []>("SELECT id FROM auth_sessions") + .all() + ).toEqual([{ id: identity.sessionId }]); + expect( + harness.database.sqlite + .query<{ action: string; metadataJson: string }, []>( + "SELECT action, metadata_json AS metadataJson FROM audit_events ORDER BY occurred_at DESC, id DESC LIMIT 1" + ) + .get() + ).toEqual({ + action: "auth.session.revoke-others", + metadataJson: JSON.stringify({ revokedSessions: 2 }), + }); + + const auditCountBeforeNoop = harness.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM audit_events" + ) + .get()?.count; + expect( + await harness.service.revokeOtherSessions(identity, { + clientSourceId: "client-source-1", + requestId: "request-revoke-others-again", + }) + ).toEqual({ revokedSessions: 0 }); + expect( + harness.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM audit_events" + ) + .get()?.count + ).toBe(auditCountBeforeNoop); + + const replacement = await harness.service.login( + { password: "current-password-1", username: "operator" }, + { + clientSourceId: "client-source-replacement", + requestId: "request-replacement", + } + ); + if (replacement.status !== "created") { + throw new Error( + `Expected login creation, received ${replacement.status}` + ); + } + harness.database.sqlite.run( + `INSERT INTO auth_pending_logins ( + allows_recovery, + allows_totp, + allows_webauthn, + authentication_version, + created_at, + expires_at, + id, + password_verified_at, + replaced_session_id, + user_id, + validator_hash + ) VALUES (1, 0, 0, 1, 1000, 2000, ?, 1000, ?, ?, ?)`, + ["c".repeat(32), replacement.session.id, created.user.id, "d".repeat(64)] + ); + expect( + harness.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM auth_pending_logins" + ) + .get() + ).toEqual({ count: 1 }); + expect( + await harness.service.revokeAllSessions(identity, { + clientSourceId: "client-source-1", + requestId: "request-revoke-all", + }) + ).toEqual({ revokedSessions: 2 }); + expect( + harness.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM auth_sessions" + ) + .get() + ).toEqual({ count: 0 }); + expect( + harness.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM auth_pending_logins" + ) + .get() + ).toEqual({ count: 0 }); + expect( + harness.database.sqlite + .query<{ action: string; metadataJson: string }, []>( + "SELECT action, metadata_json AS metadataJson FROM audit_events ORDER BY occurred_at DESC, id DESC LIMIT 1" + ) + .get() + ).toEqual({ + action: "auth.session.revoke-all", + metadataJson: JSON.stringify({ revokedSessions: 2 }), + }); + } finally { + harness.database.sqlite.close(true); + } + }); + + test("requires recent authentication for every bulk session mutation", async () => { + for (const operation of ["all", "others"] as const) { + const harness = await createAuthenticationLifecycleHarness({ + recentAuthenticationWindowMs: 60_000, + }); + + try { + const created = await bootstrapAuthenticationLifecycle(harness); + const second = await harness.service.login( + { password: "current-password-1", username: "operator" }, + { + clientSourceId: "client-source-2", + requestId: "request-second", + } + ); + if (second.status !== "created") { + throw new Error(`Expected login creation, received ${second.status}`); + } + harness.advanceSeconds(61); + const identity = { + sessionId: created.session.id, + userId: created.user.id, + }; + const metadata = { + clientSourceId: "client-source-1", + requestId: `request-stale-${operation}`, + }; + + expect( + operation === "all" + ? await harness.service.revokeAllSessions(identity, metadata) + : await harness.service.revokeOtherSessions(identity, metadata) + ).toEqual({ status: "step-up-required" }); + expect( + harness.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM auth_sessions" + ) + .get() + ).toEqual({ count: 2 }); + } finally { + harness.database.sqlite.close(true); + } + } + }); + test("audits a successful logout once without repeatable no-op growth", async () => { const harness = await createAuthenticationLifecycleHarness(); diff --git a/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts index b0237229e..b0c8e85e6 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts @@ -4,6 +4,7 @@ import type { ImmediateDatabaseWriteAdmission } from "../../database/immediateWr import type { AuthenticationRateLimitKind } from "../../database/schema/authRateLimitBuckets.ts"; import { DrizzleAuthenticationRateLimitStore } from "./authenticationRateLimitStore.ts"; import { DrizzleBrowserSessionStore } from "./browserSessionStore.ts"; +import { DrizzlePendingLoginStore } from "./pendingLoginStore.ts"; import { DrizzleSecurityAuditStore, type SecurityAuditWriter, @@ -49,7 +50,9 @@ export interface AuthenticationLifecycleUnitOfWork BrowserSessionWriter, SecurityAuditWriter { countUsers(): number; + deleteAllSessions(userId: string): number; deleteOtherSessions(userId: string, retainedSessionId: string): number; + deletePendingLoginsForUser(userId: string): number; deleteRateLimitBuckets(kind: AuthenticationRateLimitKind): number; deleteSession(userId: string, sessionId: string): boolean; insertUser(input: SecurityUserInsert): SecurityUserRecord; @@ -82,12 +85,14 @@ export interface AuthenticationLifecycleRepository { class DrizzleAuthenticationLifecycleUnitOfWork implements AuthenticationLifecycleUnitOfWork { readonly #auditEvents: DrizzleSecurityAuditStore; + readonly #pendingLogins: DrizzlePendingLoginStore; readonly #rateLimits: DrizzleAuthenticationRateLimitStore; readonly #sessions: DrizzleBrowserSessionStore; readonly #users: DrizzleSecurityUserStore; constructor(transaction: SecurityTransaction) { this.#auditEvents = new DrizzleSecurityAuditStore(transaction); + this.#pendingLogins = new DrizzlePendingLoginStore(transaction); this.#rateLimits = new DrizzleAuthenticationRateLimitStore(transaction); this.#sessions = new DrizzleBrowserSessionStore(transaction); this.#users = new DrizzleSecurityUserStore(transaction); @@ -97,10 +102,18 @@ class DrizzleAuthenticationLifecycleUnitOfWork implements AuthenticationLifecycl return this.#users.countUsers(); } + deleteAllSessions(userId: string): number { + return this.#sessions.deleteAllSessions(userId); + } + deleteOtherSessions(userId: string, retainedSessionId: string): number { return this.#sessions.deleteOtherSessions(userId, retainedSessionId); } + deletePendingLoginsForUser(userId: string): number { + return this.#pendingLogins.deleteAllForUser(userId); + } + deleteRateLimitBucket(bucketKey: string): void { this.#rateLimits.deleteRateLimitBucket(bucketKey); } diff --git a/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts b/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts index 04e5136d1..5ae2f6e8b 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts @@ -5,10 +5,12 @@ import { sessionActivityWriteIntervalMs, type AuthenticationLifecycleContext, } from "./authenticationLifecycleContext.ts"; +import type { AuthenticationLifecycleUnitOfWork } from "./authenticationLifecycleRepository.ts"; import type { AuthenticationLifecycleService } from "./authenticationLifecycleTypes.ts"; import { authSession, authUser, + type AuthenticatedBrowserIdentity, browserSessionIsActive as sessionIsActive, sessionActor, } from "./authenticationSession.ts"; @@ -23,6 +25,38 @@ type SessionsContext = Pick< | "sessionIdleDurationMs" >; +type SessionMutationAccess = "authorized" | "session-changed" | "step-up-required"; + +function sessionMutationAccess( + context: SessionsContext, + unit: AuthenticationLifecycleUnitOfWork, + identity: AuthenticatedBrowserIdentity, + checkedAt: Date +): SessionMutationAccess { + const user = unit.findUserById(identity.userId); + const actorSession = unit.findSession(identity.userId, identity.sessionId); + if ( + user === undefined || + user.disabledAt !== null || + actorSession === undefined || + actorSession.authenticationVersion !== user.authenticationVersion || + !sessionIsActive(actorSession, checkedAt, context.sessionIdleDurationMs) + ) { + return "session-changed"; + } + const recentAuthentication = evaluateRecentAuthentication({ + checkedAt, + mfaEnabledAt: user.mfaEnabledAt, + mfaVerifiedAt: actorSession.mfaVerifiedAt, + passwordVerifiedAt: actorSession.passwordVerifiedAt, + windowMs: context.recentAuthenticationWindowMs, + }); + if (user.mfaEnabledAt === null) { + return recentAuthentication.password.recent ? "authorized" : "step-up-required"; + } + return recentAuthentication.mfa.recent ? "authorized" : "step-up-required"; +} + /** * Creates session reads, activity, logout, and revocation operations. * @returns Session operations backed by the shared lifecycle context. @@ -31,7 +65,13 @@ export function createAuthenticationSessionOperations( context: SessionsContext ): Pick< AuthenticationLifecycleService, - "listSessions" | "logout" | "revokeSession" | "status" | "touchSession" + | "listSessions" + | "logout" + | "revokeAllSessions" + | "revokeOtherSessions" + | "revokeSession" + | "status" + | "touchSession" > { return { listSessions(identity) { @@ -102,36 +142,9 @@ export function createAuthenticationSessionOperations( async revokeSession(identity, sessionId, metadata) { return await context.repository.withImmediateTransaction((unit) => { const occurredAt = context.now(); - const user = unit.findUserById(identity.userId); - const actorSession = unit.findSession( - identity.userId, - identity.sessionId - ); - if ( - user === undefined || - user.disabledAt !== null || - actorSession === undefined || - actorSession.authenticationVersion !== user.authenticationVersion || - !sessionIsActive( - actorSession, - occurredAt, - context.sessionIdleDurationMs - ) - ) { - return; - } - const recentAuthentication = evaluateRecentAuthentication({ - checkedAt: occurredAt, - mfaEnabledAt: user.mfaEnabledAt, - mfaVerifiedAt: actorSession.mfaVerifiedAt, - passwordVerifiedAt: actorSession.passwordVerifiedAt, - windowMs: context.recentAuthenticationWindowMs, - }); - if ( - user.mfaEnabledAt === null - ? !recentAuthentication.password.recent - : !recentAuthentication.mfa.recent - ) { + const access = sessionMutationAccess(context, unit, identity, occurredAt); + if (access === "session-changed") return; + if (access === "step-up-required") { return { status: "step-up-required" as const }; } const revoked = unit.deleteSession(identity.userId, sessionId); @@ -151,6 +164,60 @@ export function createAuthenticationSessionOperations( }); }, + async revokeAllSessions(identity, metadata) { + return await context.repository.withImmediateTransaction((unit) => { + const occurredAt = context.now(); + const access = sessionMutationAccess(context, unit, identity, occurredAt); + if (access === "session-changed") return; + if (access === "step-up-required") { + return { status: "step-up-required" as const }; + } + unit.deletePendingLoginsForUser(identity.userId); + const revokedSessions = unit.deleteAllSessions(identity.userId); + if (revokedSessions > 0) { + context.audit(unit, { + action: "auth.session.revoke-all", + actor: sessionActor(identity), + metadata: { revokedSessions }, + occurredAt, + outcome: "succeeded", + requestId: metadata.requestId, + targetId: identity.userId, + targetType: "auth_sessions", + }); + } + return { revokedSessions }; + }); + }, + + async revokeOtherSessions(identity, metadata) { + return await context.repository.withImmediateTransaction((unit) => { + const occurredAt = context.now(); + const access = sessionMutationAccess(context, unit, identity, occurredAt); + if (access === "session-changed") return; + if (access === "step-up-required") { + return { status: "step-up-required" as const }; + } + const revokedSessions = unit.deleteOtherSessions( + identity.userId, + identity.sessionId + ); + if (revokedSessions > 0) { + context.audit(unit, { + action: "auth.session.revoke-others", + actor: sessionActor(identity), + metadata: { revokedSessions }, + occurredAt, + outcome: "succeeded", + requestId: metadata.requestId, + targetId: identity.userId, + targetType: "auth_sessions", + }); + } + return { revokedSessions }; + }); + }, + status(identity) { const isBootstrapRequired = context.repository.countUsers() === 0; if (identity === undefined || isBootstrapRequired) { diff --git a/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts b/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts index c78d0e3f5..64017732c 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts @@ -91,6 +91,10 @@ export type RevokeSessionResult = | { readonly revoked: boolean } | { readonly status: "step-up-required" }; +export type RevokeSessionsResult = + | { readonly revokedSessions: number } + | { readonly status: "step-up-required" }; + export type AuthenticationStatus = | { readonly authenticated: false; readonly isBootstrapRequired: boolean } | { @@ -127,6 +131,14 @@ export interface AuthenticationLifecycleService { sessionId: string, metadata: AuthenticationRequestMetadata ): Promise; + revokeAllSessions( + identity: AuthenticatedBrowserIdentity, + metadata: AuthenticationRequestMetadata + ): Promise; + revokeOtherSessions( + identity: AuthenticatedBrowserIdentity, + metadata: AuthenticationRequestMetadata + ): Promise; status(identity?: AuthenticatedBrowserIdentity): AuthenticationStatus; touchSession( identity: AuthenticatedBrowserIdentity diff --git a/greenfield/src/server/domains/security/browserSessionStore.ts b/greenfield/src/server/domains/security/browserSessionStore.ts index 3be8dbd52..e1318e873 100644 --- a/greenfield/src/server/domains/security/browserSessionStore.ts +++ b/greenfield/src/server/domains/security/browserSessionStore.ts @@ -27,6 +27,13 @@ export class DrizzleBrowserSessionStore { this.#database = database; } + deleteAllSessions(userId: string): number { + return this.#database + .delete(authSessions) + .where(eq(authSessions.userId, userId)) + .run().changes; + } + deleteOtherSessions(userId: string, retainedSessionId: string): number { return this.#database .delete(authSessions) diff --git a/greenfield/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts index 69657f97c..522c74802 100644 --- a/greenfield/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts +++ b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts @@ -16,6 +16,7 @@ import { userRecoveryCodeInsertSchema } from "../../../database/validation/userR import { userTotpFactorInsertSchema } from "../../../database/validation/userTotpFactors.ts"; import { userWebAuthnCredentialInsertSchema } from "../../../database/validation/userWebAuthnCredentials.ts"; import { opaqueTokenValidatorVersion } from "../../../shared/opaqueToken.ts"; +import { DrizzlePendingLoginStore } from "../pendingLoginStore.ts"; import { DrizzleSecurityAuditStore, type SecurityAuditWriter, @@ -69,12 +70,14 @@ export class DrizzleMfaLifecycleUnitOfWork implements MfaLifecycleUnitOfWork { readonly #auditEvents: DrizzleSecurityAuditStore; + readonly #pendingLogins: DrizzlePendingLoginStore; readonly #transaction: SecurityTransaction; constructor(transaction: SecurityTransaction) { super(transaction); this.#transaction = transaction; this.#auditEvents = new DrizzleSecurityAuditStore(transaction); + this.#pendingLogins = new DrizzlePendingLoginStore(transaction); } advanceTotpLastUsedStep( @@ -322,10 +325,7 @@ export class DrizzleMfaLifecycleUnitOfWork } deletePendingLoginsForUser(userId: string): number { - return this.#transaction - .delete(authPendingLogins) - .where(eq(authPendingLogins.userId, userId)) - .run().changes; + return this.#pendingLogins.deleteAllForUser(userId); } deletePendingTotpFactorsForUser(userId: string): number { diff --git a/greenfield/src/server/domains/security/pendingLoginStore.ts b/greenfield/src/server/domains/security/pendingLoginStore.ts new file mode 100644 index 000000000..e34571b4a --- /dev/null +++ b/greenfield/src/server/domains/security/pendingLoginStore.ts @@ -0,0 +1,20 @@ +import { eq } from "drizzle-orm"; + +import { authPendingLogins } from "../../database/schema/authPendingLogins.ts"; +import type { SecurityPersistenceDatabase } from "./securityPersistenceTypes.ts"; + +/** Focused persistence for invalidating password-first MFA handoffs by user. */ +export class DrizzlePendingLoginStore { + readonly #database: SecurityPersistenceDatabase; + + constructor(database: SecurityPersistenceDatabase) { + this.#database = database; + } + + deleteAllForUser(userId: string): number { + return this.#database + .delete(authPendingLogins) + .where(eq(authPendingLogins.userId, userId)) + .run().changes; + } +} diff --git a/greenfield/src/server/domains/security/procedures.test.ts b/greenfield/src/server/domains/security/procedures.test.ts index 45da97696..5c3e1e849 100644 --- a/greenfield/src/server/domains/security/procedures.test.ts +++ b/greenfield/src/server/domains/security/procedures.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import { TRPCError } from "@trpc/server"; -import { dashboardPendingLoginCookieName } from "../../rawHttp/authenticationCredentials.ts"; +import { + dashboardPendingLoginCookieName, + dashboardSessionCookieName, +} from "../../rawHttp/authenticationCredentials.ts"; import { generateOpaqueToken } from "../../shared/opaqueToken.ts"; import { captureFailure } from "../../test/support/promise.ts"; import { @@ -310,6 +313,135 @@ describe("authentication procedures", () => { expect(responseHeaders.get("set-cookie")).toBeNull(); }); + test("keeps retained credentials and clears both cookies after valid all-session output", async () => { + const otherHeaders = new Headers(); + const otherContext = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + authenticationLifecycle: createTestAuthenticationLifecycleService({ + revokeOtherSessions: () => Promise.resolve({ revokedSessions: 2 }), + }), + responseHeaders: otherHeaders, + } + ); + expect( + await appRouter.createCaller(otherContext).auth.revokeOtherSessions() + ).toEqual({ revokedSessions: 2 }); + expect(otherHeaders.get("set-cookie")).toBeNull(); + + const allHeaders = new Headers(); + const pendingLogin = generateOpaqueToken("pending-login"); + const allContext = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + authenticationLifecycle: createTestAuthenticationLifecycleService({ + revokeAllSessions: () => Promise.resolve({ revokedSessions: 3 }), + }), + request: new Request("http://localhost/trpc/auth.revokeAllSessions", { + headers: { + cookie: `${dashboardPendingLoginCookieName}=${pendingLogin.token}`, + }, + }), + responseHeaders: allHeaders, + } + ); + expect(await appRouter.createCaller(allContext).auth.revokeAllSessions()).toEqual( + { revokedSessions: 3 } + ); + const clearedCookies = allHeaders.getSetCookie(); + expect(clearedCookies).toHaveLength(2); + expect(clearedCookies).toEqual([ + expect.stringContaining(`${dashboardSessionCookieName}=; Max-Age=0`), + expect.stringContaining(`${dashboardPendingLoginCookieName}=; Max-Age=0`), + ]); + + const invalidHeaders = new Headers(); + const invalidRevokeAll = (() => + Promise.resolve({ + revokedSessions: -1, + })) as unknown as AuthenticationLifecycleService["revokeAllSessions"]; + const invalidContext = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + authenticationLifecycle: createTestAuthenticationLifecycleService({ + revokeAllSessions: invalidRevokeAll, + }), + responseHeaders: invalidHeaders, + } + ); + expect( + await captureFailure(() => + appRouter.createCaller(invalidContext).auth.revokeAllSessions() + ) + ).toBeInstanceOf(Error); + expect(invalidHeaders.get("set-cookie")).toBeNull(); + }); + + test("handles policy and identity races for bulk session revocation", async () => { + for (const operation of ["all", "others"] as const) { + const policyHeaders = new Headers(); + const policyContext = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + authenticationLifecycle: createTestAuthenticationLifecycleService( + operation === "all" + ? { + revokeAllSessions: () => + Promise.resolve({ + status: "step-up-required", + }), + } + : { + revokeOtherSessions: () => + Promise.resolve({ + status: "step-up-required", + }), + } + ), + responseHeaders: policyHeaders, + } + ); + const policyFailure = await captureFailure(() => + operation === "all" + ? appRouter.createCaller(policyContext).auth.revokeAllSessions() + : appRouter.createCaller(policyContext).auth.revokeOtherSessions() + ); + expect(policyFailure).toBeInstanceOf(TRPCError); + expect((policyFailure as TRPCError).code).toBe("FORBIDDEN"); + expect(policyHeaders.get("set-cookie")).toBeNull(); + + const staleHeaders = new Headers(); + const staleContext = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + authenticationLifecycle: createTestAuthenticationLifecycleService( + operation === "all" + ? { + revokeAllSessions: () => Promise.resolve(undefined), + } + : { + revokeOtherSessions: () => Promise.resolve(undefined), + } + ), + responseHeaders: staleHeaders, + } + ); + const staleFailure = await captureFailure(() => + operation === "all" + ? appRouter.createCaller(staleContext).auth.revokeAllSessions() + : appRouter.createCaller(staleContext).auth.revokeOtherSessions() + ); + expect(staleFailure).toBeInstanceOf(TRPCError); + expect((staleFailure as TRPCError).code).toBe("UNAUTHORIZED"); + expect(staleHeaders.get("set-cookie")).toContain("Max-Age=0"); + } + }); + test("clears stale authentication after a password-change race", async () => { const responseHeaders = new Headers(); const context = await createTestRequestContext( diff --git a/greenfield/src/server/domains/security/securityAuditLifecycle.test.ts b/greenfield/src/server/domains/security/securityAuditLifecycle.test.ts new file mode 100644 index 000000000..a70a55781 --- /dev/null +++ b/greenfield/src/server/domains/security/securityAuditLifecycle.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; + +import { createSecurityAuditEvent } from "./audit.ts"; +import { createSecurityAuditLifecycleService } from "./securityAuditLifecycle.ts"; +import { createSecurityAuditLifecycleRepository } from "./securityAuditLifecycleRepository.ts"; +import { DrizzleSecurityAuditStore } from "./securityAuditStore.ts"; +import { + bootstrapAuthenticationLifecycle, + createAuthenticationLifecycleHarness, +} from "./testSupport/authenticationLifecycle.ts"; + +const firstSameTimeId = "019fc968-1a9b-7771-8f1b-d5b863b0e7b4"; +const secondSameTimeId = "019fc968-1a9b-7772-8f1b-d5b863b0e7b4"; +const oldestId = "019fc968-1a9b-7770-8f1b-d5b863b0e7b4"; +const now = new Date("2026-08-05T09:00:00.000Z"); + +describe("security audit lifecycle", () => { + test("lists redacted events with stable keyset pagination", async () => { + const harness = await createAuthenticationLifecycleHarness(); + + try { + const created = await bootstrapAuthenticationLifecycle(harness); + const store = new DrizzleSecurityAuditStore(harness.database.orm); + const actor = { + authenticatorId: created.session.id, + id: created.user.id, + kind: "user" as const, + }; + for (const input of [ + { + id: firstSameTimeId, + occurredAt: new Date("2026-08-05T08:59:00.000Z"), + }, + { + id: secondSameTimeId, + occurredAt: new Date("2026-08-05T08:59:00.000Z"), + }, + { + id: oldestId, + occurredAt: new Date("2026-08-05T08:58:00.000Z"), + }, + ]) { + store.insertAuditEvent( + createSecurityAuditEvent({ + action: "auth.session.revoke-others", + actor, + id: input.id, + metadata: { + revokedSessions: 2, + secret: "redacted-before-storage", + } as never, + occurredAt: input.occurredAt, + outcome: "succeeded", + requestId: "request-audit-list", + targetId: created.user.id, + targetType: "auth_sessions", + }) + ); + } + const service = createSecurityAuditLifecycleService({ + now: () => now, + repository: createSecurityAuditLifecycleRepository(harness.database.orm), + }); + const identity = { + sessionId: created.session.id, + userId: created.user.id, + }; + + const firstPage = service.listEvents(identity, { limit: 2 }); + expect(firstPage.status).toBe("listed"); + if (firstPage.status !== "listed") return; + expect(firstPage.result.events).toHaveLength(2); + expect(firstPage.result.events[0]?.action).toBe("auth.bootstrap"); + expect(firstPage.result.events[1]).toMatchObject({ + id: secondSameTimeId, + metadata: { revokedSessions: 2 }, + }); + expect(JSON.stringify(firstPage.result)).not.toContain( + "redacted-before-storage" + ); + expect(firstPage.result.nextCursor).toEqual({ + id: secondSameTimeId, + occurredAtMs: new Date("2026-08-05T08:59:00.000Z").getTime(), + }); + + const secondPage = service.listEvents(identity, { + cursor: firstPage.result.nextCursor, + limit: 2, + }); + expect(secondPage).toMatchObject({ + result: { + events: [{ id: firstSameTimeId }, { id: oldestId }], + }, + status: "listed", + }); + if (secondPage.status === "listed") { + expect(secondPage.result.nextCursor).toBeUndefined(); + } + } finally { + harness.database.sqlite.close(true); + } + }); + + test("fails closed before a cursor can bypass future or unclassified history", async () => { + for (const defect of ["future", "metadata"] as const) { + const harness = await createAuthenticationLifecycleHarness(); + + try { + const created = await bootstrapAuthenticationLifecycle(harness); + if (defect === "future") { + new DrizzleSecurityAuditStore(harness.database.orm).insertAuditEvent( + createSecurityAuditEvent({ + action: "auth.logout", + actor: { + authenticatorId: created.session.id, + id: created.user.id, + kind: "user", + }, + id: Bun.randomUUIDv7(), + occurredAt: new Date("2026-08-05T09:00:01.000Z"), + outcome: "succeeded", + targetId: created.session.id, + targetType: "auth_session", + }) + ); + } else { + harness.database.sqlite.run( + "INSERT INTO audit_events (action, actor_id, actor_kind, authenticator_id, id, metadata_json, occurred_at, outcome, request_id, target_id, target_type) VALUES (?, ?, 'user', ?, ?, ?, ?, 'succeeded', NULL, ?, 'auth_session')", + [ + "auth.logout", + created.user.id, + created.session.id, + Bun.randomUUIDv7(), + JSON.stringify({ credential: "must-not-escape" }), + new Date("2026-08-05T08:59:00.000Z").getTime(), + created.session.id, + ] + ); + } + const service = createSecurityAuditLifecycleService({ + now: () => now, + repository: createSecurityAuditLifecycleRepository( + harness.database.orm + ), + }); + + expect(() => + service.listEvents( + { + sessionId: created.session.id, + userId: created.user.id, + }, + { limit: 20 } + ) + ).toThrow(); + } finally { + harness.database.sqlite.close(true); + } + } + }); + + test("returns session-changed without exposing history for a stale actor", async () => { + const harness = await createAuthenticationLifecycleHarness(); + + try { + const created = await bootstrapAuthenticationLifecycle(harness); + harness.database.sqlite.run("DELETE FROM auth_sessions WHERE id = ?", [ + created.session.id, + ]); + const service = createSecurityAuditLifecycleService({ + now: () => now, + repository: createSecurityAuditLifecycleRepository(harness.database.orm), + }); + + expect( + service.listEvents( + { + sessionId: created.session.id, + userId: created.user.id, + }, + { limit: 20 } + ) + ).toEqual({ status: "session-changed" }); + } finally { + harness.database.sqlite.close(true); + } + }); +}); diff --git a/greenfield/src/server/domains/security/securityAuditLifecycle.ts b/greenfield/src/server/domains/security/securityAuditLifecycle.ts new file mode 100644 index 000000000..6b6d8c63f --- /dev/null +++ b/greenfield/src/server/domains/security/securityAuditLifecycle.ts @@ -0,0 +1,140 @@ +import { getTime } from "date-fns"; +import * as v from "valibot"; + +import { + type ListSecurityAuditEventsInput, + type ListSecurityAuditEventsResult, + listSecurityAuditEventsResultSchema, + securityAuditMetadataSchema, +} from "../../../contracts/securityAudit.ts"; +import { nonnegativeDateAction } from "../../../shared/dateTime.ts"; +import { parseJsonText } from "../../../shared/json.ts"; +import { parseBrowserSessionIdleDurationMs } from "./authenticationPolicy.ts"; +import { + browserSessionIsActive, + type AuthenticatedBrowserIdentity, +} from "./authenticationSession.ts"; +import type { + SecurityAuditLifecycleReader, + SecurityAuditLifecycleRepository, +} from "./securityAuditLifecycleRepository.ts"; +import type { SecurityAuditEventRecord } from "./securityAuditStore.ts"; + +const securityAuditClockSchema = v.pipe( + v.date("Security audit clock is invalid"), + nonnegativeDateAction("Security audit clock is invalid") +); + +export interface SecurityAuditLifecycleDependencies { + readonly now?: () => Date; + readonly repository: SecurityAuditLifecycleRepository; + readonly sessionIdleDurationMs?: number; +} + +export type ListSecurityAuditEventsLifecycleResult = + | { readonly result: ListSecurityAuditEventsResult; readonly status: "listed" } + | { readonly status: "session-changed" }; + +export interface SecurityAuditLifecycleService { + listEvents( + identity: AuthenticatedBrowserIdentity, + input: ListSecurityAuditEventsInput + ): ListSecurityAuditEventsLifecycleResult; +} + +function sessionIsCurrent( + reader: SecurityAuditLifecycleReader, + identity: AuthenticatedBrowserIdentity, + checkedAt: Date, + sessionIdleDurationMs: number +): boolean { + const user = reader.findUserById(identity.userId); + const session = reader.findSession(identity.userId, identity.sessionId); + return ( + user !== undefined && + user.disabledAt === null && + session !== undefined && + session.authenticationVersion === user.authenticationVersion && + browserSessionIsActive(session, checkedAt, sessionIdleDurationMs) + ); +} + +function auditEventSummary(record: SecurityAuditEventRecord) { + const actor = + record.authenticatorId === null + ? { id: record.actorId, kind: record.actorKind } + : { + authenticatorId: record.authenticatorId, + id: record.actorId, + kind: record.actorKind, + }; + return { + action: record.action, + actor, + id: record.id, + metadata: v.parse( + securityAuditMetadataSchema, + parseJsonText(record.metadataJson) + ), + occurredAtMs: getTime(record.occurredAt), + outcome: record.outcome, + ...(record.requestId === null ? {} : { requestId: record.requestId }), + target: { id: record.targetId, type: record.targetType }, + }; +} + +/** + * Creates the session-only, read-only security audit inventory lifecycle. + * @returns A lifecycle that revalidates the actor inside every read transaction. + */ +export function createSecurityAuditLifecycleService( + dependencies: SecurityAuditLifecycleDependencies +): SecurityAuditLifecycleService { + const clock = dependencies.now ?? (() => new Date()); + const now = () => v.parse(securityAuditClockSchema, clock()); + const sessionIdleDurationMs = parseBrowserSessionIdleDurationMs( + dependencies.sessionIdleDurationMs + ); + + return Object.freeze({ + listEvents( + identity: AuthenticatedBrowserIdentity, + input: ListSecurityAuditEventsInput + ) { + return dependencies.repository.withReadTransaction((reader) => { + const checkedAt = now(); + if ( + !sessionIsCurrent(reader, identity, checkedAt, sessionIdleDurationMs) + ) { + return { status: "session-changed" as const }; + } + if (reader.hasFutureEvents(checkedAt)) { + throw new Error("Security audit history contains a future event"); + } + const rows = reader.listEvents({ + ...(input.cursor === undefined + ? {} + : { + beforeId: input.cursor.id, + beforeOccurredAt: new Date(input.cursor.occurredAtMs), + }), + limit: input.limit + 1, + }); + const page = rows.slice(0, input.limit); + const last = page.at(-1); + const result = v.parse(listSecurityAuditEventsResultSchema, { + events: page.map((event) => auditEventSummary(event)), + ...(rows.length > input.limit && last !== undefined + ? { + nextCursor: { + id: last.id, + occurredAtMs: getTime(last.occurredAt), + }, + } + : {}), + }); + return { result, status: "listed" as const }; + }); + }, + }); +} diff --git a/greenfield/src/server/domains/security/securityAuditLifecycleRepository.ts b/greenfield/src/server/domains/security/securityAuditLifecycleRepository.ts new file mode 100644 index 000000000..31e8bb5c0 --- /dev/null +++ b/greenfield/src/server/domains/security/securityAuditLifecycleRepository.ts @@ -0,0 +1,81 @@ +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; + +import { DrizzleBrowserSessionStore } from "./browserSessionStore.ts"; +import { + DrizzleSecurityAuditStore, + type SecurityAuditEventListInput, + type SecurityAuditEventRecord, +} from "./securityAuditStore.ts"; +import type { + BrowserSessionRecord, + SecurityTransaction, + SecurityUserRecord, + SynchronousResult, +} from "./securityPersistenceTypes.ts"; +import { DrizzleSecurityUserStore } from "./securityUserStore.ts"; + +export interface SecurityAuditLifecycleReader { + findSession(userId: string, sessionId: string): BrowserSessionRecord | undefined; + findUserById(userId: string): SecurityUserRecord | undefined; + hasFutureEvents(checkedAt: Date): boolean; + listEvents(input: SecurityAuditEventListInput): SecurityAuditEventRecord[]; +} + +export interface SecurityAuditLifecycleRepository { + withReadTransaction( + callback: (reader: SecurityAuditLifecycleReader) => SynchronousResult + ): T; +} + +class DrizzleSecurityAuditLifecycleReader implements SecurityAuditLifecycleReader { + readonly #audit: DrizzleSecurityAuditStore; + readonly #sessions: DrizzleBrowserSessionStore; + readonly #users: DrizzleSecurityUserStore; + + public constructor(transaction: SecurityTransaction) { + this.#audit = new DrizzleSecurityAuditStore(transaction); + this.#sessions = new DrizzleBrowserSessionStore(transaction); + this.#users = new DrizzleSecurityUserStore(transaction); + } + + findSession(userId: string, sessionId: string): BrowserSessionRecord | undefined { + return this.#sessions.findSession(userId, sessionId); + } + + findUserById(userId: string): SecurityUserRecord | undefined { + return this.#users.findUserById(userId); + } + + hasFutureEvents(checkedAt: Date): boolean { + return this.#audit.hasFutureEvents(checkedAt); + } + + listEvents(input: SecurityAuditEventListInput): SecurityAuditEventRecord[] { + return this.#audit.listEvents(input); + } +} + +/** + * Creates the read-only transaction boundary for immutable security audit history. + * @returns A repository whose synchronous callbacks run in deferred transactions. + */ +export function createSecurityAuditLifecycleRepository( + database: SQLiteBunDatabase +): SecurityAuditLifecycleRepository { + const runTransaction = database.transaction.bind(database) as unknown as ( + callback: (transaction: SecurityTransaction) => T, + config: { behavior: "deferred" } + ) => T; + + return Object.freeze({ + withReadTransaction( + callback: (reader: SecurityAuditLifecycleReader) => SynchronousResult + ): T { + return runTransaction( + (transaction) => + callback(new DrizzleSecurityAuditLifecycleReader(transaction)), + { behavior: "deferred" } + ); + }, + }); +} diff --git a/greenfield/src/server/domains/security/securityAuditProcedures.test.ts b/greenfield/src/server/domains/security/securityAuditProcedures.test.ts new file mode 100644 index 000000000..b6d442f0c --- /dev/null +++ b/greenfield/src/server/domains/security/securityAuditProcedures.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; + +import { TRPCError } from "@trpc/server"; + +import { captureFailure } from "../../test/support/promise.ts"; +import { + createTestApplicationRuntime, + createTestAutomationAuthentication, + createTestRequestContext, + createTestSecurityAuditLifecycleService, + createTestSessionAuthentication, +} from "../../test/support/requestContext.ts"; +import { appRouter } from "../../trpc/appRouter.ts"; + +describe("security audit procedures", () => { + test("rejects anonymous and automation callers", async () => { + for (const testCase of [ + { authentication: undefined, code: "UNAUTHORIZED" }, + { + authentication: createTestAutomationAuthentication(["reports:read"]), + code: "FORBIDDEN", + }, + ] as const) { + const context = await createTestRequestContext(testCase.authentication); + const failure = await captureFailure(() => + appRouter.createCaller(context).securityAudit.listEvents({ limit: 20 }) + ); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe(testCase.code); + } + }); + + test("returns validated redacted history for a browser session", async () => { + const event = { + action: "auth.logout", + actor: { + authenticatorId: "a".repeat(32), + id: "019fc968-1a9b-7770-8f1b-d5b863b0e7b4", + kind: "user" as const, + }, + id: "019fc968-1a9b-7771-8f1b-d5b863b0e7b4", + metadata: {}, + occurredAtMs: 1_800_000_000_000, + outcome: "succeeded" as const, + target: { id: "a".repeat(32), type: "auth_session" }, + }; + const context = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + securityAuditLifecycle: createTestSecurityAuditLifecycleService({ + listEvents: () => ({ + result: { events: [event] }, + status: "listed", + }), + }), + } + ); + + expect( + await appRouter.createCaller(context).securityAudit.listEvents({ + limit: 20, + }) + ).toEqual({ events: [event] }); + }); + + test("clears the cookie when session state changes", async () => { + const responseHeaders = new Headers(); + const context = await createTestRequestContext( + createTestSessionAuthentication([]), + createTestApplicationRuntime(), + { + responseHeaders, + securityAuditLifecycle: createTestSecurityAuditLifecycleService(), + } + ); + + const failure = await captureFailure(() => + appRouter.createCaller(context).securityAudit.listEvents({ limit: 20 }) + ); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe("UNAUTHORIZED"); + expect(responseHeaders.get("set-cookie")).toContain("Max-Age=0"); + }); +}); diff --git a/greenfield/src/server/domains/security/securityAuditProcedures.ts b/greenfield/src/server/domains/security/securityAuditProcedures.ts new file mode 100644 index 000000000..90549efcf --- /dev/null +++ b/greenfield/src/server/domains/security/securityAuditProcedures.ts @@ -0,0 +1,10 @@ +import { router } from "../../trpc/trpc.ts"; +import { securityAuditRoutes } from "./securityAuditRoutes.ts"; + +/** Leaf procedure names owned by the security-audit router composition. */ +export const securityAuditProcedureNames = Object.freeze( + Object.keys(securityAuditRoutes) +); + +/** Browser-session-only immutable security audit router. */ +export const securityAuditRouter = router(securityAuditRoutes); diff --git a/greenfield/src/server/domains/security/securityAuditRoutes.ts b/greenfield/src/server/domains/security/securityAuditRoutes.ts new file mode 100644 index 000000000..fa19b4a3f --- /dev/null +++ b/greenfield/src/server/domains/security/securityAuditRoutes.ts @@ -0,0 +1,61 @@ +import { TRPCError } from "@trpc/server"; +import * as v from "valibot"; + +import { + type SecurityAuditEventSummary, + listSecurityAuditEventsInputSchema, + listSecurityAuditEventsResultSchema, + securityAuditEventSummarySchema, +} from "../../../contracts/securityAudit.ts"; +import { appendClearedDashboardSessionCookie } from "../../rawHttp/sessionCookie.ts"; +import { sessionProcedure } from "../../trpc/trpc.ts"; + +function mutableAuditEvent( + event: SecurityAuditEventSummary +): v.InferInput { + const { addedCapabilities, removedCapabilities, ...metadata } = event.metadata; + return { + ...event, + metadata: { + ...metadata, + ...(addedCapabilities === undefined + ? {} + : { + addedCapabilities: [...addedCapabilities], + }), + ...(removedCapabilities === undefined + ? {} + : { + removedCapabilities: [...removedCapabilities], + }), + }, + }; +} + +/** Browser-session-only immutable security audit routes. */ +export const securityAuditRoutes = { + listEvents: sessionProcedure + .input(listSecurityAuditEventsInputSchema) + .output(listSecurityAuditEventsResultSchema) + .query(({ ctx, input }) => { + const result = ctx.securityAuditLifecycle.listEvents( + ctx.sessionIdentity, + input + ); + if (result.status === "listed") { + const output = v.parse( + listSecurityAuditEventsResultSchema, + result.result + ); + return { + ...output, + events: output.events.map(mutableAuditEvent), + }; + } + appendClearedDashboardSessionCookie(ctx.responseHeaders); + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication state changed; sign in again", + }); + }), +}; diff --git a/greenfield/src/server/domains/security/securityAuditStore.ts b/greenfield/src/server/domains/security/securityAuditStore.ts index 45c237c71..fa306d8bf 100644 --- a/greenfield/src/server/domains/security/securityAuditStore.ts +++ b/greenfield/src/server/domains/security/securityAuditStore.ts @@ -1,16 +1,48 @@ +import { and, desc, eq, gt, lt, or } from "drizzle-orm"; import * as v from "valibot"; import { auditEvents } from "../../database/schema/auditEvents.ts"; -import { auditEventInsertSchema } from "../../database/validation/auditEvents.ts"; +import { + auditEventInsertSchema, + auditEventSelectSchema, +} from "../../database/validation/auditEvents.ts"; import type { SecurityAuditEvent } from "./audit.ts"; import type { SecurityPersistenceDatabase } from "./securityPersistenceTypes.ts"; +export type SecurityAuditEventRecord = v.InferOutput; + +export interface SecurityAuditEventListInput { + readonly beforeId?: string; + readonly beforeOccurredAt?: Date; + readonly limit: number; +} + +function assertSecurityAuditListInput(input: SecurityAuditEventListInput): void { + if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 256) { + throw new RangeError("Security audit event list limit is invalid"); + } + if ((input.beforeId === undefined) !== (input.beforeOccurredAt === undefined)) { + throw new TypeError("Security audit event cursor is incomplete"); + } +} + +function parseSecurityAuditEvent(row: unknown): SecurityAuditEventRecord { + return v.parse(auditEventSelectSchema, row); +} + +export interface SecurityAuditReader { + hasFutureEvents(checkedAt: Date): boolean; + listEvents(input: SecurityAuditEventListInput): SecurityAuditEventRecord[]; +} + export interface SecurityAuditWriter { insertAuditEvent(event: SecurityAuditEvent): void; } /** Focused validated persistence for append-only security audit events. */ -export class DrizzleSecurityAuditStore implements SecurityAuditWriter { +export class DrizzleSecurityAuditStore + implements SecurityAuditReader, SecurityAuditWriter +{ readonly #database: SecurityPersistenceDatabase; constructor(database: SecurityPersistenceDatabase) { @@ -23,4 +55,37 @@ export class DrizzleSecurityAuditStore implements SecurityAuditWriter { .values(v.parse(auditEventInsertSchema, event)) .run(); } + + hasFutureEvents(checkedAt: Date): boolean { + return ( + this.#database + .select({ id: auditEvents.id }) + .from(auditEvents) + .where(gt(auditEvents.occurredAt, checkedAt)) + .limit(1) + .get() !== undefined + ); + } + + listEvents(input: SecurityAuditEventListInput): SecurityAuditEventRecord[] { + assertSecurityAuditListInput(input); + const boundary = + input.beforeId === undefined || input.beforeOccurredAt === undefined + ? undefined + : or( + lt(auditEvents.occurredAt, input.beforeOccurredAt), + and( + eq(auditEvents.occurredAt, input.beforeOccurredAt), + lt(auditEvents.id, input.beforeId) + ) + ); + return this.#database + .select() + .from(auditEvents) + .where(boundary) + .orderBy(desc(auditEvents.occurredAt), desc(auditEvents.id)) + .limit(input.limit) + .all() + .map((row) => parseSecurityAuditEvent(row)); + } } diff --git a/greenfield/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts index 4be657423..6a1e5bd28 100644 --- a/greenfield/src/server/test/support/requestContext.ts +++ b/greenfield/src/server/test/support/requestContext.ts @@ -14,6 +14,7 @@ import type { import type { AutomationSecurityLifecycleService } from "../../domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../../domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../../domains/security/mfa/loginLifecycle.ts"; +import type { SecurityAuditLifecycleService } from "../../domains/security/securityAuditLifecycle.ts"; import { createStructuredLogger, type StructuredLogger, @@ -231,6 +232,12 @@ export function createTestAuthenticationLifecycleService( overrides.login ?? (() => Promise.resolve({ status: "bootstrap-required" as const })), logout: overrides.logout ?? (() => Promise.resolve(false)), + revokeAllSessions: + overrides.revokeAllSessions ?? + (() => Promise.resolve({ revokedSessions: 0 })), + revokeOtherSessions: + overrides.revokeOtherSessions ?? + (() => Promise.resolve({ revokedSessions: 0 })), revokeSession: overrides.revokeSession ?? (() => Promise.resolve({ revoked: false })), status: @@ -240,6 +247,20 @@ export function createTestAuthenticationLifecycleService( }); } +/** + * Creates an inert security-audit lifecycle that fails closed unless overridden. + * @param overrides Lifecycle methods exercised by the current test. + * @returns A complete inert security-audit lifecycle. + */ +export function createTestSecurityAuditLifecycleService( + overrides: Partial = {} +): SecurityAuditLifecycleService { + return Object.freeze({ + listEvents: + overrides.listEvents ?? (() => ({ status: "session-changed" as const })), + }); +} + /** * Creates a fail-closed automation-security lifecycle for transport tests. * @param overrides Lifecycle methods exercised by the current test. @@ -362,6 +383,7 @@ export interface TestServerSecurityServices { readonly automationSecurityLifecycle: AutomationSecurityLifecycleService; readonly mfaAccountLifecycle: MfaAccountLifecycleService; readonly mfaLoginLifecycle: MfaLoginLifecycleService; + readonly securityAuditLifecycle: SecurityAuditLifecycleService; } /** @@ -386,6 +408,8 @@ export function createTestServerSecurityServices( overrides.mfaAccountLifecycle ?? createTestMfaAccountLifecycleService(), mfaLoginLifecycle: overrides.mfaLoginLifecycle ?? createTestMfaLoginLifecycleService(), + securityAuditLifecycle: + overrides.securityAuditLifecycle ?? createTestSecurityAuditLifecycleService(), }; } @@ -469,6 +493,7 @@ export function createTestRequestContext( readonly request?: Request; readonly requestId?: string; readonly responseHeaders?: Headers; + readonly securityAuditLifecycle?: SecurityAuditLifecycleService; } = {} ): Promise { const request = options.request ?? new Request("http://localhost/trpc/test"); @@ -492,5 +517,7 @@ export function createTestRequestContext( request, requestId: options.requestId ?? "test-request-id", responseHeaders: options.responseHeaders ?? new Headers(), + securityAuditLifecycle: + options.securityAuditLifecycle ?? createTestSecurityAuditLifecycleService(), }); } diff --git a/greenfield/src/server/trpc/appRouter.ts b/greenfield/src/server/trpc/appRouter.ts index ed51db8c6..9cfec8426 100644 --- a/greenfield/src/server/trpc/appRouter.ts +++ b/greenfield/src/server/trpc/appRouter.ts @@ -8,6 +8,10 @@ import { accountSecurityRouter, } from "../domains/security/mfa/procedures.ts"; import { authProcedureNames, authRouter } from "../domains/security/procedures.ts"; +import { + securityAuditProcedureNames, + securityAuditRouter, +} from "../domains/security/securityAuditProcedures.ts"; import { systemProcedureNames, systemRouter } from "../domains/system/procedures.ts"; import { router } from "./trpc.ts"; @@ -24,6 +28,7 @@ export const appRouter = router({ auth: authRouter, automationSecurity: automationSecurityRouter, events: eventsRouter, + securityAudit: securityAuditRouter, system: systemRouter, }); @@ -33,6 +38,7 @@ export const appRouterProcedureNames = Object.freeze([ ...namespacedProcedureNames("auth", authProcedureNames), ...namespacedProcedureNames("automationSecurity", automationSecurityProcedureNames), ...namespacedProcedureNames("events", eventsProcedureNames), + ...namespacedProcedureNames("securityAudit", securityAuditProcedureNames), ...namespacedProcedureNames("system", systemProcedureNames), ]); diff --git a/greenfield/src/server/trpc/context.test.ts b/greenfield/src/server/trpc/context.test.ts index 66ec7532c..0939f79bc 100644 --- a/greenfield/src/server/trpc/context.test.ts +++ b/greenfield/src/server/trpc/context.test.ts @@ -8,6 +8,7 @@ import { createTestAutomationSecurityLifecycleService, createTestMfaAccountLifecycleService, createTestMfaLoginLifecycleService, + createTestSecurityAuditLifecycleService, } from "../test/support/requestContext.ts"; import { createRequestContext } from "./context.ts"; @@ -59,6 +60,7 @@ describe("tRPC request context", () => { request, requestId: "request-context-1", responseHeaders, + securityAuditLifecycle: createTestSecurityAuditLifecycleService(), }); expect(observedCredential).toEqual({ kind: "anonymous" }); @@ -114,6 +116,7 @@ describe("tRPC request context", () => { request, requestId: "request-context-2", responseHeaders: new Headers(), + securityAuditLifecycle: createTestSecurityAuditLifecycleService(), }); expect(context.userAgent).toBeUndefined(); @@ -150,6 +153,7 @@ describe("tRPC request context", () => { request, requestId: "request-context-3", responseHeaders: new Headers(), + securityAuditLifecycle: createTestSecurityAuditLifecycleService(), }); } catch (error) { failure = error; diff --git a/greenfield/src/server/trpc/context.ts b/greenfield/src/server/trpc/context.ts index f079450de..9e49fbc5d 100644 --- a/greenfield/src/server/trpc/context.ts +++ b/greenfield/src/server/trpc/context.ts @@ -7,6 +7,7 @@ import { import type { AutomationSecurityLifecycleService } from "../domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../domains/security/mfa/loginLifecycle.ts"; +import type { SecurityAuditLifecycleService } from "../domains/security/securityAuditLifecycle.ts"; import type { ApplicationRuntime, ApplicationRuntimeServices, @@ -33,6 +34,7 @@ export interface RequestContextOptions { readonly request: Request; readonly requestId: string; readonly responseHeaders: Headers; + readonly securityAuditLifecycle: SecurityAuditLifecycleService; } /** Dependencies supplied to every application tRPC procedure. */ @@ -47,6 +49,7 @@ export interface RequestContext { readonly pendingLoginCredential: PendingLoginCredential; readonly requestId: string; readonly responseHeaders: Headers; + readonly securityAuditLifecycle: SecurityAuditLifecycleService; readonly services: ApplicationRuntimeServices; readonly userAgent?: string; } @@ -74,6 +77,7 @@ export async function createRequestContext( pendingLoginCredential: options.pendingLoginCredential, requestId: options.requestId, responseHeaders: options.responseHeaders, + securityAuditLifecycle: options.securityAuditLifecycle, services: options.applicationRuntime.services, ...(userAgent !== null && { userAgent }), }); diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts index 0e6d50dd9..b996ebbef 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.ts @@ -137,6 +137,8 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "auth.loginTotp": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], "auth.loginWebAuthn": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], "auth.logout": ["SERVICE_UNAVAILABLE"], + "auth.revokeAllSessions": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "auth.revokeOtherSessions": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "auth.revokeSession": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "auth.sessions": ["FORBIDDEN", "UNAUTHORIZED"], "auth.status": [], @@ -194,6 +196,7 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], + "securityAudit.listEvents": ["FORBIDDEN", "UNAUTHORIZED"], "system.runtimeIdentity": [], } as const satisfies ProcedureExpectedErrorPolicy); diff --git a/greenfield/src/shared/validation.test.ts b/greenfield/src/shared/validation.test.ts index ddb37fc29..b8a84e4d2 100644 --- a/greenfield/src/shared/validation.test.ts +++ b/greenfield/src/shared/validation.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import * as v from "valibot"; import { + boundedNonBlankTextSchema, fullCommitShaSchema, lowercaseSha256Schema, lowercaseUuidV7Schema, @@ -76,6 +77,17 @@ describe("shared scalar validation", () => { } }); + test("bounds persistence text by Unicode code points and rejects unsafe text", () => { + const schema = boundedNonBlankTextSchema(2); + + for (const value of ["a", "ab", "😀", "😀😀"]) { + expect(v.parse(schema, value)).toBe(value); + } + for (const value of ["", " ", "abc", "😀😀😀", "a\0"]) { + expect(v.safeParse(schema, value).success).toBeFalse(); + } + }); + test("uses a caller-supplied operational error message", () => { const validation = v.safeParse( nonnegativeSafeIntegerSchema("Domain-specific integer error"), diff --git a/greenfield/src/shared/validation.ts b/greenfield/src/shared/validation.ts index 0357ee805..2a7f3a606 100644 --- a/greenfield/src/shared/validation.ts +++ b/greenfield/src/shared/validation.ts @@ -36,6 +36,8 @@ function decimalRangePattern(maximum: number): RegExp { const CANONICAL_NONNEGATIVE_SAFE_INTEGER_PATTERN = decimalRangePattern( Number.MAX_SAFE_INTEGER ); +type StringRequirement = (value: string) => boolean; +const boundedNonBlankTextMaximumLengths = new WeakMap(); /** * Builds a bounded string schema that rejects blank values. @@ -60,7 +62,16 @@ export function boundedNonBlankStringSchema( * @returns Valibot action for a NUL-free string. */ export function noNulStringAction(message = "Expected a string without NUL characters.") { - return v.check((value: string) => !value.includes("\0"), message); + return v.check(hasNoNulCharacter, message); +} + +/** + * Tests the named persistence-safe NUL exclusion shared with JSON Schema generation. + * @param value String value to inspect. + * @returns Whether the string contains no embedded NUL character. + */ +export function hasNoNulCharacter(value: string): boolean { + return !value.includes("\0"); } function unicodeCodePointLength(value: string): number { @@ -79,17 +90,32 @@ export function boundedNonBlankTextSchema( maximumLength: number, message = "Expected bounded non-blank text." ) { + const hasValidCodePointLength = (value: string): boolean => { + const length = unicodeCodePointLength(value); + return length > 0 && length <= maximumLength; + }; + boundedNonBlankTextMaximumLengths.set(hasValidCodePointLength, maximumLength); return v.pipe( v.string(message), - v.check((value) => { - const length = unicodeCodePointLength(value); - return length > 0 && length <= maximumLength; - }, message), + v.check(hasValidCodePointLength, message), v.regex(/\S/u, message), noNulStringAction(message) ); } +/** + * Reads the Unicode code-point budget carried by a bounded-text requirement. + * @param requirement Valibot check requirement to inspect. + * @returns The registered maximum code-point length, when this module created it. + */ +export function getBoundedNonBlankTextMaximumLength( + requirement: unknown +): number | undefined { + return typeof requirement === "function" + ? boundedNonBlankTextMaximumLengths.get(requirement as StringRequirement) + : undefined; +} + /** * Tests the named scalar-array uniqueness rule shared by Valibot and JSON Schema generation. * Restricting this helper to JSON scalars keeps Set equality equivalent to `uniqueItems`. diff --git a/greenfield/src/test/integration/build/frontendBuildScenario.ts b/greenfield/src/test/integration/build/frontendBuildScenario.ts index 7ad2522d0..b9e14aa3c 100644 --- a/greenfield/src/test/integration/build/frontendBuildScenario.ts +++ b/greenfield/src/test/integration/build/frontendBuildScenario.ts @@ -26,10 +26,10 @@ export interface FrontendBuildScenarioEvidence { const frontendBuildFixtureEntrypoint = path.resolve( import.meta.dir, - "../../../browser/testSupport/frontendBuildFixture/index.html" + "../../../browser/test/fixtures/frontendBuild/index.html" ); const frontendBuildFixtureAppInput = - "src/browser/testSupport/frontendBuildFixture/src/main.tsx"; + "src/browser/test/fixtures/frontendBuild/src/main.tsx"; export const frontendBuildPluginOrder = [ reactCompilerPlugin.name, diff --git a/greenfield/src/test/parity/fixtures/greenfield-contracts.json b/greenfield/src/test/parity/fixtures/greenfield-contracts.json index dda86a82f..cc56df709 100644 --- a/greenfield/src/test/parity/fixtures/greenfield-contracts.json +++ b/greenfield/src/test/parity/fixtures/greenfield-contracts.json @@ -94,6 +94,14 @@ "kind": "mutation", "name": "auth.logout" }, + { + "kind": "mutation", + "name": "auth.revokeAllSessions" + }, + { + "kind": "mutation", + "name": "auth.revokeOtherSessions" + }, { "kind": "mutation", "name": "auth.revokeSession" @@ -146,6 +154,10 @@ "kind": "subscription", "name": "events.stream" }, + { + "kind": "query", + "name": "securityAudit.listEvents" + }, { "kind": "query", "name": "system.runtimeIdentity" diff --git a/greenfield/src/test/parity/fixtures/legacy-endpoints.json b/greenfield/src/test/parity/fixtures/legacy-endpoints.json index 2cdf82df6..ecd7a732b 100644 --- a/greenfield/src/test/parity/fixtures/legacy-endpoints.json +++ b/greenfield/src/test/parity/fixtures/legacy-endpoints.json @@ -208,7 +208,7 @@ "purpose": "Pages append-only redacted events newest-first (`limit`, `before` cursor).", "section": "Audit", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["securityAudit.listEvents"], "phase": "phase-2" @@ -1127,7 +1127,7 @@ "purpose": "Revokes every session, including the current one.", "section": "Account Security", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["auth.revokeAllSessions"], "phase": "phase-2" @@ -1140,7 +1140,7 @@ "purpose": "Revokes every other browser session.", "section": "Account Security", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["auth.revokeOtherSessions"], "phase": "phase-2" diff --git a/greenfield/src/test/parity/parityInventory.test.ts b/greenfield/src/test/parity/parityInventory.test.ts index 0708ad4ed..964916b76 100644 --- a/greenfield/src/test/parity/parityInventory.test.ts +++ b/greenfield/src/test/parity/parityInventory.test.ts @@ -116,7 +116,7 @@ describe("reviewed pre-cutover parity inventory", () => { ).toThrow("is not registered"); }); - test("keeps unresolved Phase 2 behavior explicit", async () => { + test("keeps the Phase 2 server endpoint inventory closed", async () => { const reviewed = await loadReviewedParityInventory(); expect( @@ -128,10 +128,6 @@ describe("reviewed pre-cutover parity inventory", () => { target.delivery === "planned" ) .map(({ id }) => id) - ).toEqual([ - "GET /api/audit-events", - "POST /api/account/security/sessions/revoke-all", - "POST /api/account/security/sessions/revoke-others", - ]); + ).toEqual([]); }); }); diff --git a/greenfield/src/test/setup.test.ts b/greenfield/src/test/setup.test.ts new file mode 100644 index 000000000..2e0c84664 --- /dev/null +++ b/greenfield/src/test/setup.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +describe("test process setup", () => { + test("pins ambient project and temporary paths to one private test root", async () => { + const projectRoot = process.env.MIRA_DASHBOARD_PROJECT_ROOT; + if (projectRoot === undefined) throw new Error("Expected a test project root"); + + const status = await stat(projectRoot); + expect(process.env.NODE_ENV).toBe("test"); + expect(tmpdir()).toBe(projectRoot); + expect(process.env.TEMP).toBe(projectRoot); + expect(process.env.TMP).toBe(projectRoot); + expect(process.env.TMPDIR).toBe(projectRoot); + expect(path.basename(projectRoot)).toStartWith("mira-dashboard-test-"); + expect(status.isDirectory()).toBe(true); + expect(status.mode & 0o777).toBe(0o700); + }); +}); diff --git a/greenfield/src/test/setup.ts b/greenfield/src/test/setup.ts new file mode 100644 index 000000000..affd992d9 --- /dev/null +++ b/greenfield/src/test/setup.ts @@ -0,0 +1,45 @@ +import { afterAll, afterEach, jest } from "bun:test"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const managedEnvironmentNames = Object.freeze([ + "MIRA_DASHBOARD_PROJECT_ROOT", + "NODE_ENV", + "TEMP", + "TMP", + "TMPDIR", +] as const); +const originalEnvironment = new Map( + managedEnvironmentNames.map((name) => [name, process.env[name]] as const) +); + +// Prevent test processes from inheriting a real project layout or temporary database root. +const testRoot = await mkdtemp(path.join(tmpdir(), "mira-dashboard-test-")); +await chmod(testRoot, 0o700); + +process.env.NODE_ENV = "test"; +process.env.MIRA_DASHBOARD_PROJECT_ROOT = testRoot; +process.env.TEMP = testRoot; +process.env.TMP = testRoot; +process.env.TMPDIR = testRoot; + +function restoreEnvironment(): void { + for (const name of managedEnvironmentNames) { + const value = originalEnvironment.get(name); + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +afterAll(async () => { + restoreEnvironment(); + await rm(testRoot, { force: true, recursive: true }); +}); diff --git a/greenfield/tsconfig.browser.json b/greenfield/tsconfig.browser.json index bc9c26541..7e5ad21a2 100644 --- a/greenfield/tsconfig.browser.json +++ b/greenfield/tsconfig.browser.json @@ -7,6 +7,7 @@ "jsx": "react-jsx" }, "files": [ + "node_modules/@testing-library/jest-dom/types/bun.d.ts", "node_modules/bun-types/test.d.ts", "src/test/types/bunCanaryMatchers.d.ts" ],