From 19e7211a37af65880dcd72c51112462dd981cccf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 10 Oct 2026 23:44:34 +0200 Subject: [PATCH 01/10] feat(auth): Hazel auth on @yielded/auth with Postgres storage GitHub and Google sign-in, passkeys and stateful sessions, stored in new auth_* tables (Drizzle schema in packages/db) with users as the auth subject. Auth storage runs on a SqlClient routed to the current request's connection, so it works with the api Worker's per-request sockets. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 83 +++- packages/auth/package.json | 18 +- packages/auth/src/yielded/access-token.ts | 66 ++++ packages/auth/src/yielded/auth.ts | 68 ++++ packages/auth/src/yielded/contract.ts | 52 +++ packages/auth/src/yielded/hazel-auth.test.ts | 374 +++++++++++++++++++ packages/auth/src/yielded/index.ts | 17 + packages/auth/src/yielded/policy.ts | 127 +++++++ packages/auth/src/yielded/server.ts | 188 ++++++++++ packages/auth/src/yielded/sql.ts | 89 +++++ packages/auth/src/yielded/storage.ts | 251 +++++++++++++ packages/auth/src/yielded/tables.ts | 213 +++++++++++ packages/db/src/schema/auth.ts | 185 +++++++++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/users.ts | 4 + 15 files changed, 1732 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/yielded/access-token.ts create mode 100644 packages/auth/src/yielded/auth.ts create mode 100644 packages/auth/src/yielded/contract.ts create mode 100644 packages/auth/src/yielded/hazel-auth.test.ts create mode 100644 packages/auth/src/yielded/index.ts create mode 100644 packages/auth/src/yielded/policy.ts create mode 100644 packages/auth/src/yielded/server.ts create mode 100644 packages/auth/src/yielded/sql.ts create mode 100644 packages/auth/src/yielded/storage.ts create mode 100644 packages/auth/src/yielded/tables.ts create mode 100644 packages/db/src/schema/auth.ts diff --git a/bun.lock b/bun.lock index 7539135be..b8dfd696e 100644 --- a/bun.lock +++ b/bun.lock @@ -504,14 +504,23 @@ "version": "0.0.0", "dependencies": { "@clerk/backend": "^3.3.0", + "@effect/sql-pg": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", "@hazel/schema": "workspace:*", + "@simplewebauthn/server": "^14.0.1", + "@yielded/auth": "0.1.0-beta.32", + "@yielded/auth-persistence": "0.1.0-beta.32", + "@yielded/auth-simplewebauthn": "0.1.0-beta.32", + "@yielded/crypto": "0.1.0-beta.32", + "@yielded/jose": "0.1.0-beta.32", "effect": "catalog:effect", "jose": "^6.1.3", + "tldts": "^7.4.11", }, "devDependencies": { + "@effect/platform-node": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -1260,6 +1269,8 @@ "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], @@ -1372,6 +1383,8 @@ "@legendapp/list": ["@legendapp/list@3.0.0-beta.41", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-m/wxzotZDE2z9Wh/l8LkLpKNydbc+nOgXm2Ao6yxYv3Rml8go+IdaPzHz4PsLplzWdSS3fOz5omKIxnBC6B9Cw=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@libsql/client": ["@libsql/client@0.17.4", "", { "dependencies": { "@libsql/core": "^0.17.4", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw=="], "@libsql/core": ["@libsql/core@0.17.4", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ=="], @@ -1590,6 +1603,36 @@ "@paper-design/shaders-react": ["@paper-design/shaders-react@0.0.71", "", { "dependencies": { "@paper-design/shaders": "0.0.71" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-kTqjIlyZcpkwqJie+3ldEDscTtx1oOi8eRBD5QgWKI21GaNn/SSg26092M5zzqr3e8dVANv0ktS2ICSjbMFKbw=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA=="], + + "@peculiar/asn1-asym-key": ["@peculiar/asn1-asym-key@2.10.0", "", { "dependencies": { "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.10.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.10.0", "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-rsa": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.10.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.10.0", "@peculiar/asn1-pfx": "^2.10.0", "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.10.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg=="], + + "@peculiar/asn1-x509-post-quantum": ["@peculiar/asn1-x509-post-quantum@2.10.0", "", { "dependencies": { "@peculiar/asn1-asym-key": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@2.1.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-csr": "^2.9.4", "@peculiar/asn1-ecc": "^2.9.4", "@peculiar/asn1-pkcs9": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -2070,6 +2113,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@simplewebauthn/server": ["@simplewebauthn/server@14.0.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "@peculiar/x509": "^2.1.0", "reflect-metadata": "^0.2.2" } }, "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q=="], + "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], @@ -2402,6 +2447,18 @@ "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@yielded/auth": ["@yielded/auth@0.1.0-beta.32", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.32", "@yielded/jose": "0.1.0-beta.32", "@yielded/oauth": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-HnXi2oJmtaLbmvNaaymx0mWyyIalmIpCAYa1+8OErq2ABFZX1YeUOo03bC9Rx2yb04kA7wNUQPHlSoPoLbjnuA=="], + + "@yielded/auth-persistence": ["@yielded/auth-persistence@0.1.0-beta.32", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.32", "@yielded/crypto": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-WzJUn1K6+2PK+VNq1RiTJJPvKmju6UvQzoP2Vas4g9HIoO/dk19nphkYAnaYXBj681U3mXw/yyqNVDiTNx+XXA=="], + + "@yielded/auth-simplewebauthn": ["@yielded/auth-simplewebauthn@0.1.0-beta.32", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.32", "tldts": "7.4.11" }, "peerDependencies": { "@simplewebauthn/browser": ">=14.0.0 <15", "@simplewebauthn/server": ">=14.0.1 <15", "effect": "^4.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server"] }, "sha512-DTbsU5PjPrubT46huO/w/JhKl0+NBkTI0tEdkr+uAV5xJzWm5+9pdDNS+kYhNAoTwPwlvlCK6AsDT2vZRIgYgA=="], + + "@yielded/crypto": ["@yielded/crypto@0.1.0-beta.32", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-m3N1s94E0RPqgCFqHtQmBq3GQiXApzb2rYJOkTEtAyqtj9Yv22FWBdEUT7s5Y/lR3YbQK0JReymp/Saj0Kwu5g=="], + + "@yielded/jose": ["@yielded/jose@0.1.0-beta.32", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-bOtE4GJldDM+Mcb5UirTfNapRPmRJ4RPic/XcPamIdpqI0G/37DcaIMlyVX6Jc1xQFExVW7mQsl9VO61F3e9LA=="], + + "@yielded/oauth": ["@yielded/oauth@0.1.0-beta.32", "", { "dependencies": { "@yielded/jose": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-4vCkTwY0Cs3wixMAGU6KywFuVG+m0QuuH8lV166wXCSPbk24DKAK/faZqtiVmwwpRVjJSeonvcGNI4II01mzHw=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -2456,6 +2513,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], @@ -3788,6 +3847,10 @@ "punycode2": ["punycode2@1.0.1", "", {}, "sha512-+TXpd9YRW4YUZZPoRHJ3DILtWwootGc2DsgvfHmklQ8It1skINAuqSdqizt5nlTaBmwrYACHkHApCXjc9gHk2Q=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -3852,6 +3915,8 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -4174,9 +4239,9 @@ "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], - "tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + "tldts": ["tldts@7.4.18", "", { "dependencies": { "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg=="], - "tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "tldts-core": ["tldts-core@7.4.18", "", {}, "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], @@ -4202,6 +4267,8 @@ "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="], @@ -4722,6 +4789,8 @@ "@metascraper/helpers/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@metascraper/helpers/tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], @@ -4914,6 +4983,8 @@ "@vitest/coverage-istanbul/magicast": ["magicast@0.5.1", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "source-map-js": "^1.2.1" } }, "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw=="], + "@yielded/auth-simplewebauthn/tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="], + "alchemy/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "alchemy/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], @@ -5138,6 +5209,8 @@ "tinyglobby/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "tough-cookie/tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + "tsdown/rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], "tsdown/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -5146,6 +5219,8 @@ "tsx/get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "unist-util-remove-position/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "unrun/rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], @@ -5590,6 +5665,8 @@ "@metascraper/helpers/jsdom/whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + "@metascraper/helpers/tldts/tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -6064,6 +6141,8 @@ "ssh-remote-port-forward/@types/ssh2/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "tsdown/rolldown/@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], "tsdown/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="], diff --git a/packages/auth/package.json b/packages/auth/package.json index f000b3df1..7ea2d5761 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -8,19 +8,33 @@ ".": "./src/index.ts", "./backend": "./src/consumers/backend-auth.ts", "./proxy": "./src/consumers/proxy-auth.ts", - "./errors": "./src/errors.ts" + "./errors": "./src/errors.ts", + "./contract": "./src/yielded/contract.ts", + "./server": "./src/yielded/index.ts" }, "dependencies": { "@clerk/backend": "^3.3.0", + "@effect/sql-pg": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", "@hazel/schema": "workspace:*", + "@simplewebauthn/server": "^14.0.1", + "@yielded/auth": "0.1.0-beta.32", + "@yielded/auth-persistence": "0.1.0-beta.32", + "@yielded/auth-simplewebauthn": "0.1.0-beta.32", + "@yielded/crypto": "0.1.0-beta.32", + "@yielded/jose": "0.1.0-beta.32", "effect": "catalog:effect", - "jose": "^6.1.3" + "jose": "^6.1.3", + "tldts": "^7.4.11" }, "devDependencies": { + "@effect/platform-node": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" + }, + "scripts": { + "test:integration": "vitest run src/yielded" } } diff --git a/packages/auth/src/yielded/access-token.ts b/packages/auth/src/yielded/access-token.ts new file mode 100644 index 000000000..e284f5003 --- /dev/null +++ b/packages/auth/src/yielded/access-token.ts @@ -0,0 +1,66 @@ +import * as Jwk from "@yielded/jose/Jwk" +import * as Jwks from "@yielded/jose/Jwks" +import * as Jwt from "@yielded/jose/Jwt" +import { DateTime, Effect, Redacted, Schema } from "effect" + +/** + * Short-lived access token for services on other hosts (electric.hazel.sh, rivet.hazel.sh). + * The browser's session cookie is host-only on api.hazel.sh, so those services can't see + * it. The backend exchanges a valid session for this ES256 JWT; the services verify it + * locally against the published JWKS, without a database or network call. + */ + +export const issuer = "https://api.hazel.sh" +export const audience = "hazel-services" +export const lifetimeSeconds = 300 + +export const AccessClaims = Schema.Struct({ + iss: Schema.String, + aud: Schema.String, + sub: Schema.String, + sid: Schema.String, + iat: Schema.Finite, + exp: Schema.Finite, +}) + +export const makeIssuer = (input: { + readonly kid: string + readonly privateJwk: Redacted.Redacted +}) => + Effect.gen(function* () { + const key = yield* Jwk.importPrivate(input.privateJwk, "ES256") + + return { + mint: (session: { readonly subjectId: string; readonly sessionId: string }) => + Effect.gen(function* () { + const now = Math.floor(DateTime.toEpochMillis(yield* DateTime.now) / 1000) + return yield* Jwt.sign( + AccessClaims, + { + iss: issuer, + aud: audience, + sub: session.subjectId, + sid: session.sessionId, + iat: now, + exp: now + lifetimeSeconds, + }, + key, + { alg: "ES256", kid: input.kid, typ: "at+jwt" }, + ) + }), + } + }) + +/** Verification used by electric-proxy / actors. Requires `Jwks` (local or remote). */ +export const verifyAccessToken = (token: string) => + Jwt.verifyWithKeySet(AccessClaims, Redacted.make(token), { + algorithms: ["ES256"], + issuer, + audience, + typ: "at+jwt", + requiredClaims: ["iss", "sub", "aud", "exp", "iat"], + }) + +export const generateSigningKey = (kid: string) => Jwk.generateKeyPair({ algorithm: "ES256", kid }) + +export { Jwks } diff --git a/packages/auth/src/yielded/auth.ts b/packages/auth/src/yielded/auth.ts new file mode 100644 index 000000000..2f1f42a88 --- /dev/null +++ b/packages/auth/src/yielded/auth.ts @@ -0,0 +1,68 @@ +import { Auth, OAuth, Passkey, Sessions } from "@yielded/auth" +import * as GitHub from "@yielded/auth/GitHub" +import * as Google from "@yielded/auth/Google" + +import { HazelAuthApi, Registration } from "./contract.ts" + +/** One namespace for sign-in, registration and linked-account management, so all + * three share the same login credentials. */ +export const oauthNamespace = "hazel/oauth" + +export const oauthPolicy = { + generation: 1, + lifetimeMillis: 300_000, + exchangeTimeoutMillis: 30_000, +} + +export const sessionConfiguration = Sessions.stateful({ + maxAge: "30 days", + idleTimeout: "14 days", +}) + +export const HazelAuth = Auth.make(HazelAuthApi, { + sessions: sessionConfiguration, + strategies: { + social: OAuth.makeRegistration({ + namespace: oauthNamespace, + profiles: { github: GitHub.GitHubUserProfile, google: Google.GoogleUserProfile }, + registration: Registration, + authenticate: true, + policy: oauthPolicy, + registrationPolicy: { + lifetimeMillis: 300_000, + maximumVerificationAgeMillis: 300_000, + retentionMillis: 600_000, + }, + }), + accounts: OAuth.makeAccounts({ + namespace: oauthNamespace, + policy: { + ...oauthPolicy, + maximumEvidenceAgeMillis: 300_000, + requireImmediateInvalidation: true, + }, + }), + passkey: Passkey.make(), + passkeys: Passkey.makeManagement({ + management: { + maximumCredentials: 10, + maximumEvidenceAgeMillis: 300_000, + requireImmediateInvalidation: true, + }, + }), + }, + defaultStrategy: "social", +}) + +/** Either an OAuth login (possession) or a user-verified passkey satisfies sign-in. */ +export const requirement = Sessions.AuthenticationRequirement.make({ + alternatives: [ + { + factors: ["possession"], + userVerified: false, + phishingResistant: false, + minimumCredentials: 1, + }, + ], + maximumAgeMillis: 300_000, +}) diff --git a/packages/auth/src/yielded/contract.ts b/packages/auth/src/yielded/contract.ts new file mode 100644 index 000000000..7d2d1022e --- /dev/null +++ b/packages/auth/src/yielded/contract.ts @@ -0,0 +1,52 @@ +import { AuthContract, PasskeyContract } from "@yielded/auth" +import { Schema } from "effect" + +// Browser-safe shared contract. No provider configuration, keys or persistence here. + +export const Registration = Schema.Struct({ + firstName: Schema.NonEmptyString.check(Schema.isMaxLength(100)), + lastName: Schema.String.check(Schema.isMaxLength(100)), +}) + +export const Claims = Schema.Struct({ + displayName: Schema.String, +}) + +const passkeys = PasskeyContract.makeManagement("hazel/passkeys").operations + +export const HazelAuthApi = AuthContract.make("hazel", { + claims: Claims, + actions: (sessions) => { + const passkey = PasskeyContract.make("hazel/passkey", sessions).operations + + return { + signIn: AuthContract.oauthSignIn(), + completeSignIn: AuthContract.oauthCompleteSignIn(sessions), + // authenticate: the first confirmed registration issues the session directly. + register: AuthContract.oauthRegister(sessions, Registration, { authenticate: true }), + listLinkedAccounts: AuthContract.oauthListLinkedAccounts({ strategy: "accounts" }), + passkeySignIn: AuthContract.fromOperation(passkey.Begin, { + strategy: "passkey", + method: "signIn", + }), + completePasskeySignIn: AuthContract.fromOperation(passkey.Complete, { + strategy: "passkey", + method: "completeSignIn", + requestFields: { bindingCredential: "request-binding" }, + subject: { + fromSuccess: (result) => + result._tag === "Authenticated" ? result.session.subjectId : undefined, + }, + }), + enrollPasskey: AuthContract.fromOperation(passkeys.Begin, { strategy: "passkeys" }), + completePasskeyEnrollment: AuthContract.fromOperation(passkeys.Complete, { + strategy: "passkeys", + requestFields: { bindingCredential: "request-binding" }, + }), + listPasskeys: AuthContract.fromOperation(passkeys.List, { + strategy: "passkeys", + mode: "query", + }), + } + }, +}) diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts new file mode 100644 index 000000000..fa4a8e968 --- /dev/null +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -0,0 +1,374 @@ +/** + * End-to-end over real HTTP routes and a real Postgres whose schema is pushed from + * packages/db (drizzle-kit), so the library's mappings are checked against Hazel's actual + * tables. Only GitHub's network endpoints are simulated. + * + * Auth storage gets a fresh, lazily-connecting client per request, exactly as on the + * Cloudflare Worker. Set AUTH_TEST_DATABASE_URL to a disposable database to run; the + * test drops and recreates its public schema. + */ +import { execFileSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { PgClient } from "@effect/sql-pg" +import { Hooks } from "@yielded/auth" +import { Context, Effect, Exit, Layer, Redacted, References, Scope } from "effect" +import { Base64Url } from "effect/encoding" +import { + Etag, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/http" +import { SqlClient } from "effect/sql" +import { afterAll, beforeAll, describe, expect, test } from "vitest" + +import * as AccessToken from "./access-token.ts" +import { HazelAuth } from "./auth.ts" +import { CryptoLive, makeHazelAuthLive } from "./server.ts" +import { AuthSqlConnection, AuthSqlDirect, layerDirect, makeRequestConnection } from "./sql.ts" + +const DATABASE_URL = process.env.AUTH_TEST_DATABASE_URL +const origin = "http://localhost:3003" +const webOrigin = "http://localhost:3000" + +const keyring = (byte: number) => ({ + activeKeyId: "v1", + keys: [{ id: "v1", material: Redacted.make(Base64Url.encode(new Uint8Array(32).fill(byte))) }], +}) + +// ---- Simulated GitHub: token endpoint, /user and /user/emails ---- +let githubUserId = 0 +const FakeGitHub = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + const json = (body: unknown) => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }), + ) + if (url.href === "https://github.com/login/oauth/access_token") + return json({ + access_token: `gho_${githubUserId}`, + token_type: "bearer", + scope: "read:user,user:email", + }) + if (url.href === "https://api.github.com/user") + return json({ + id: githubUserId, + login: `octo${githubUserId}`, + name: "Octo Cat", + email: null, + avatar_url: `https://avatars.githubusercontent.com/u/${githubUserId}`, + }) + if (url.href.startsWith("https://api.github.com/user/emails")) + return json([{ email: `octo${githubUserId}@example.com`, primary: true, verified: true }]) + return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) + }), + ), +) + +describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { + const url = Redacted.make(DATABASE_URL ?? "") + const TestSql = PgClient.layer({ url }) + const query = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(TestSql))) + + const { http, Services, sessionCookieName } = makeHazelAuthLive({ + origin, + trustedOrigins: [webOrigin], + github: { clientId: "Iv1.test", clientSecret: Redacted.make("test-secret") }, + passkey: { rpId: "localhost", origins: [webOrigin] }, + keys: { binding: keyring(1), transaction: keyring(2) }, + returnTargets: ["/"], + registrationPath: "/auth/register-profile", + }) + + const MeRoute = HttpRouter.add( + "GET", + "/me", + Effect.gen(function* () { + const auth = yield* HazelAuth + const session = yield* auth.getSession() + if (session === null) return HttpServerResponse.empty({ status: 401 }) + return yield* HttpServerResponse.json({ + userId: session.subjectId, + displayName: session.claims.displayName, + }) + }), + ) + + // What the RPC middleware does: verify a credential from a header, outside http.middleware. + const VerifyRoute = Layer.unwrap( + Effect.gen(function* () { + const auth = yield* HazelAuth + return HttpRouter.add( + "POST", + "/internal/verify", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const credential = request.headers["x-session-credential"] ?? "" + return yield* auth.verifySession(Redacted.make(credential)).pipe( + Effect.flatMap((session) => HttpServerResponse.json({ userId: session.subjectId })), + Effect.catchTag("SessionInvalid", () => + Effect.succeed(HttpServerResponse.empty({ status: 401 })), + ), + ) + }), + ) + }), + ) + + const App = Layer.mergeAll(http.routes(), MeRoute.pipe(http.middleware), VerifyRoute).pipe( + Layer.provide(http.layer), + Layer.provideMerge(Services), + Layer.provide(FakeGitHub), + Layer.provide(Hooks.LifecycleHooks.empty), + // Counts the dedicated connections opened outside a request connection. + Layer.provide( + Layer.effect( + AuthSqlDirect, + Effect.map(Effect.service(AuthSqlDirect), (direct) => ({ + open: direct.open.pipe(Effect.tap(() => Effect.sync(() => directOpened++))), + })), + ).pipe(Layer.provide(layerDirect(url))), + ), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layer]), + ) + + let web: ReturnType + let connectionsOpened = 0 + let directOpened = 0 + + /** Each request gets its own lazily-connecting auth client, closed after the response. */ + const send = async (request: Request): Promise => { + const scope = Effect.runSync(Scope.make()) + const connection = await Effect.runPromise( + makeRequestConnection(url, scope).pipe( + Effect.map((connection) => ({ + client: connection.client.pipe(Effect.tap(() => Effect.sync(() => connectionsOpened++))), + })), + ), + ) + try { + const handler = web.handler as ( + request: Request, + context: Context.Context, + ) => Promise + const response = await handler( + request, + Context.make(AuthSqlConnection, connection).pipe( + Context.add(References.MinimumLogLevel, process.env.DEBUG_AUTH ? "Debug" : "Info"), + ) as Context.Context, + ) + // Buffer the body so the request's connection can close. + const body = await response.arrayBuffer() + return new Response(response.status === 204 || response.status === 303 ? null : body, response) + } finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + } + + beforeAll(async () => { + await query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + yield* sql.unsafe(`DROP SCHEMA public CASCADE`) + yield* sql.unsafe(`CREATE SCHEMA public`) + }), + ) + // The real schema, exactly as deployed. + execFileSync("bunx", ["drizzle-kit", "push", "--force"], { + cwd: fileURLToPath(new URL("../../../db", import.meta.url)), + env: { ...process.env, DATABASE_URL }, + stdio: "pipe", + }) + web = HttpRouter.toWebHandler(App, { disableLogger: true }) + }, 120_000) + + afterAll(async () => { + await web?.dispose() + }) + + const jar = () => { + const cookies = new Map() + return { + cookies, + header: () => [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + store: (response: Response) => { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(";") + const index = pair!.indexOf("=") + const value = pair!.slice(index + 1) + if (value === "" || /max-age=0/i.test(raw)) cookies.delete(pair!.slice(0, index)) + else cookies.set(pair!.slice(0, index), value) + } + }, + } + } + type Jar = ReturnType + + const call = async (cookies: Jar, method: "GET" | "POST", path: string, payload?: unknown) => { + const headers: Record = { origin: webOrigin, cookie: cookies.header() } + if (method === "POST") { + headers["content-type"] = "application/json" + headers["x-effect-auth-csrf"] = "1" + } + const response = await send( + new Request(`${origin}${path}`, { + method, + headers, + ...(method === "POST" + ? { body: JSON.stringify(payload === undefined ? {} : { payload }) } + : {}), + redirect: "manual", + }), + ) + cookies.store(response) + return response + } + + const signInWithGitHub = async (cookies: Jar) => { + const begin = await call(cookies, "POST", "/auth/signIn", { provider: "github", returnTarget: "/" }) + expect(begin.status).toBe(200) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + const callback = await send( + new Request( + `${origin}/auth/github/callback?${new URLSearchParams({ code: "abc", state, iss: "https://github.com/login/oauth" })}`, + { headers: { cookie: cookies.header() }, redirect: "manual" }, + ), + ) + cookies.store(callback) + expect(callback.status).toBe(303) + return new URL(callback.headers.get("location")!, origin) + } + + const register = (cookies: Jar, location: URL) => + call(cookies, "POST", "/auth/register", { + reference: location.searchParams.get("reference"), + flowId: location.searchParams.get("flowId"), + commandId: crypto.randomUUID(), + registration: { firstName: "Octo", lastName: "Cat" }, + }) + + test("new GitHub user registers into Hazel's users table and is signed in", async () => { + githubUserId = 4242 + const browser = jar() + + const location = await signInWithGitHub(browser) + expect(location.pathname).toBe("/auth/register-profile") + const registered = await register(browser, location) + expect(await registered.json()).toMatchObject({ _tag: "Success", value: { _tag: "Authenticated" } }) + expect(browser.cookies.has(sessionCookieName)).toBe(true) + + const me = await (await call(browser, "GET", "/me")).json() + expect(me.displayName).toBe("Octo Cat") + + const [user] = await query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return yield* sql<{ + externalId: string + email: string + userType: string + isOnboarded: boolean + }>`select "externalId", email, "userType", "isOnboarded" from users where id = ${me.userId}::uuid` + }), + ) + expect(user).toEqual({ + externalId: `hazel:${me.userId}`, + email: "octo4242@example.com", + userType: "user", + isOnboarded: false, + }) + }, 60_000) + + test("returning user signs in; sign-out revokes the session server-side", async () => { + githubUserId = 4242 + const browser = jar() + expect((await signInWithGitHub(browser)).pathname).toBe("/") + expect((await call(browser, "GET", "/me")).status).toBe(200) + + const before = browser.header() + expect((await call(browser, "POST", "/auth/signOut")).status).toBe(200) + const replay = await send( + new Request(`${origin}/me`, { headers: { origin: webOrigin, cookie: before } }), + ) + expect(replay.status).toBe(401) + }, 60_000) + + test("a session credential verifies outside the auth middleware", async () => { + githubUserId = 4242 + const browser = jar() + await signInWithGitHub(browser) + const verify = (credential: string) => + send( + new Request(`${origin}/internal/verify`, { + method: "POST", + headers: { "x-session-credential": credential }, + }), + ) + const ok = await verify(browser.cookies.get(sessionCookieName)!) + expect(ok.status).toBe(200) + expect((await verify("nope")).status).toBe(401) + }, 60_000) + + test("after startup's schema checks, storage only uses the request's connection", async () => { + githubUserId = 4242 + const direct = directOpened + const opened = connectionsOpened + const browser = jar() + await signInWithGitHub(browser) + expect((await call(browser, "GET", "/me")).status).toBe(200) + expect(connectionsOpened).toBeGreaterThan(opened) + expect(directOpened).toBe(direct) + }, 60_000) + + test("requests that never touch auth storage open no database connection", async () => { + const opened = connectionsOpened + const response = await send(new Request(`${origin}/me`, { headers: { origin: webOrigin } })) + expect(response.status).toBe(401) + expect(connectionsOpened).toBe(opened) + }, 60_000) + + test("signed-in user can begin passkey enrollment", async () => { + githubUserId = 4242 + const browser = jar() + await signInWithGitHub(browser) + const response = await call(browser, "POST", "/auth/enrollPasskey", { + flowId: crypto.randomUUID(), + commandId: crypto.randomUUID(), + profileId: "default", + name: "Laptop", + }) + expect(response.status).toBe(200) + expect(JSON.stringify(await response.json())).toContain("localhost") + }, 60_000) + + test("session exchanges for an access token that verifies offline", async () => { + const pair = await Effect.runPromise( + AccessToken.generateSigningKey("k1").pipe(Effect.provide(CryptoLive)), + ) + const issuer = await Effect.runPromise( + AccessToken.makeIssuer({ kid: "k1", privateJwk: pair.privateKey }).pipe( + Effect.provide(CryptoLive), + ), + ) + const token = await Effect.runPromise( + issuer + .mint({ subjectId: "00000000-0000-4000-8000-000000000001", sessionId: "s1" }) + .pipe(Effect.provide(CryptoLive)), + ) + const verified = await Effect.runPromise( + AccessToken.verifyAccessToken(Redacted.value(token)).pipe( + Effect.provide(AccessToken.Jwks.layerLocal({ keys: [pair.publicKey] })), + Effect.provide(CryptoLive), + ), + ) + expect(verified.claims.sub).toBe("00000000-0000-4000-8000-000000000001") + }) +}) diff --git a/packages/auth/src/yielded/index.ts b/packages/auth/src/yielded/index.ts new file mode 100644 index 000000000..db30c15c1 --- /dev/null +++ b/packages/auth/src/yielded/index.ts @@ -0,0 +1,17 @@ +/** + * Server side of Hazel's authentication on `@yielded/auth`: GitHub and Google sign-in, + * passkeys and stateful sessions stored in Postgres. The browser-safe contract is + * `@hazel/auth/contract`. + */ +export * as AccessToken from "./access-token.ts" +export { HazelAuth, requirement } from "./auth.ts" +export { Claims, HazelAuthApi, Registration } from "./contract.ts" +export { + CryptoLive, + type HazelAuthConfig, + type Keyring, + makeHazelAuthHttp, + makeHazelAuthLive, + sessionCookieName, +} from "./server.ts" +export { AuthSqlConnection, AuthSqlDirect, layerDirect, layerPool, makeRequestConnection } from "./sql.ts" diff --git a/packages/auth/src/yielded/policy.ts b/packages/auth/src/yielded/policy.ts new file mode 100644 index 000000000..52c14e2bb --- /dev/null +++ b/packages/auth/src/yielded/policy.ts @@ -0,0 +1,127 @@ +import { Auth, OAuth, Passkey, Sessions } from "@yielded/auth" +import { Effect, Layer, Option, Schema } from "effect" + +import { HazelAuth, requirement, sessionConfiguration } from "./auth.ts" + +/** + * Sensitive account actions (linking a provider, adding/removing a passkey) must be + * authorised by the application against the caller's current session. The library + * supplies the challenge; Hazel decides whether this session's original evidence still + * matches the account's current credentials and security revision. + */ + +const SessionReader = HazelAuth.sessions.statefulLayer( + sessionConfiguration.policy(HazelAuth.sessions.moduleId), +) + +type Revision = { + readonly subjectId: string + readonly securityRevision: string + readonly credentials: ReadonlyArray<{ readonly credentialId: string; readonly revision: string }> +} + +const sameAuthority = (original: Revision, current: Revision) => + original.subjectId === current.subjectId && + original.securityRevision === current.securityRevision && + original.credentials.every((old) => + current.credentials.some( + (now) => now.credentialId === old.credentialId && now.revision === old.revision, + ), + ) + +const currentSessionToken = Effect.serviceOption(Auth.AuthRequest).pipe( + Effect.map((request) => (Option.isSome(request) ? request.value.credentials.session : undefined)), +) + +const PasskeyActionsLive = Layer.effect( + Passkey.PasskeyActionEvidence, + Effect.gen(function* () { + const sessions = yield* HazelAuth.sessions.SessionStrategy + + return Passkey.PasskeyActionEvidence.of({ + verify: Effect.fn("HazelAuth.authorizePasskeyAction")( + function* ({ invocation, challenge }) { + const token = yield* currentSessionToken + if (invocation._tag !== "Authenticated" || token === undefined) + return yield* Passkey.PasskeyActionRequired.make({}) + + const { inspection } = yield* HazelAuth.sessions + .inspectInvocation(invocation, token) + .pipe(Effect.provideService(HazelAuth.sessions.SessionStrategy, sessions)) + const original = inspection.provenance.evidence + + if ( + inspection.session.sessionId !== invocation.sessionId || + inspection.session.subjectId !== invocation.subjectId || + !sameAuthority(original.revision, challenge.revision) + ) + return yield* Passkey.PasskeyActionRequired.make({}) + + return { + evidence: { + ...original, + revision: challenge.revision, + flowId: Sessions.AuthenticationFlowId.make(challenge.flowId), + bindingDigest: challenge.bindingDigest, + }, + requirement, + } + }, + Effect.mapError((error) => + Schema.is(Passkey.PasskeyActionRequired)(error) + ? error + : Passkey.PasskeyUnavailable.make({}), + ), + ), + }) + }), +) + +const OAuthActionsLive = Layer.effect( + OAuth.OAuthActionEvidence, + Effect.gen(function* () { + const sessions = yield* HazelAuth.sessions.SessionStrategy + + return OAuth.OAuthActionEvidence.of({ + verify: Effect.fn("HazelAuth.authorizeOAuthAction")( + function* ({ invocation, challenge }) { + const token = yield* currentSessionToken + if (invocation._tag !== "Authenticated" || token === undefined) + return yield* OAuth.OAuthActionRequired.make({}) + + const { inspection } = yield* HazelAuth.sessions + .inspectInvocation(invocation, token) + .pipe(Effect.provideService(HazelAuth.sessions.SessionStrategy, sessions)) + const original = inspection.provenance.evidence + + if (!sameAuthority(original.revision, challenge.revision)) + return yield* OAuth.OAuthActionRequired.make({}) + + return { + source: { + _tag: "Session" as const, + sessionId: inspection.session.sessionId, + authenticatedAt: inspection.session.assurance.authenticatedAt, + }, + evidence: { + ...original, + revision: challenge.revision, + flowId: Sessions.AuthenticationFlowId.make(challenge.flowId), + bindingDigest: challenge.bindingDigest, + }, + requirement, + } + }, + Effect.mapError((error) => + Schema.is(OAuth.OAuthActionRequired)(error) || Schema.is(Sessions.SessionInvalid)(error) + ? OAuth.OAuthActionRequired.make({}) + : OAuth.OAuthUnavailable.make({}), + ), + ), + }) + }), +) + +export const ActionPoliciesLive = Layer.mergeAll(PasskeyActionsLive, OAuthActionsLive).pipe( + Layer.provide(SessionReader), +) diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts new file mode 100644 index 000000000..fc57c4c78 --- /dev/null +++ b/packages/auth/src/yielded/server.ts @@ -0,0 +1,188 @@ +import { Auth, Http, OAuth, Operations, Passkey } from "@yielded/auth" +import * as GitHub from "@yielded/auth/GitHub" +import * as Google from "@yielded/auth/Google" +import * as SimpleWebAuthn from "@yielded/auth-simplewebauthn/Server" +import * as KdfAdmission from "@yielded/crypto/KdfAdmission" +import * as PortableCrypto from "@yielded/crypto/Portable" +import * as WebCrypto from "@yielded/crypto/WebCrypto" +import { Effect, Layer, Option, type Redacted, Schema } from "effect" +import { SqlClient } from "effect/sql" + +import { HazelAuth } from "./auth.ts" +import { HazelAuthApi } from "./contract.ts" +import { ActionPoliciesLive } from "./policy.ts" +import { layerRouted } from "./sql.ts" +import { OAuthStorageLive, Persistence, storage } from "./storage.ts" + +/** + * WebCrypto plus the library's pure-JS XChaCha20-Poly1305 (OAuth state encryption), the + * same on Bun and Workers. There are no passwords, so the KDF is never used. + */ +export const CryptoLive = Layer.merge( + WebCrypto.layerCryptoWeb, + Layer.suspend(() => PortableCrypto.layer(globalThis.crypto.subtle)).pipe( + Layer.provideMerge(KdfAdmission.layer()), + ), +) + +export interface Keyring { + readonly activeKeyId: string + readonly keys: ReadonlyArray<{ readonly id: string; readonly material: Redacted.Redacted }> +} + +export interface HazelAuthConfig { + /** The backend origin that serves /auth/* (api.hazel.sh; localhost:3003 in dev). */ + readonly origin: string + /** The web app origin(s) calling the API with credentials (app.hazel.sh). */ + readonly trustedOrigins?: ReadonlyArray + readonly github: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } + readonly google?: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } + readonly passkey: { readonly rpId: string; readonly origins: ReadonlyArray } + readonly keys: { + readonly binding: Keyring + readonly transaction: Keyring + } + /** Paths the OAuth callback may return to after sign-in. */ + readonly returnTargets: ReadonlyArray + /** Where the callback sends a first-time user to pick their name. */ + readonly registrationPath: string +} + +/** The browser session cookie: `__Host-` prefixed whenever the API is served over HTTPS. */ +export const sessionCookieName = (origin: string) => + new URL(origin).protocol === "https:" ? "__Host-effect-auth-session" : "effect-auth-session" + +export const makeHazelAuthHttp = (config: HazelAuthConfig) => { + const secure = new URL(config.origin).protocol === "https:" + + const github = GitHub.provider({ + clientId: config.github.clientId, + clientSecret: config.github.clientSecret, + // Requests user:email and reads the verified primary address from /user/emails, + // so users with a private GitHub email still register with a real address. + verifiedPrimaryEmail: true, + }) + const google = (google: NonNullable) => + Google.provider({ clientId: google.clientId, clientSecret: google.clientSecret }) + const providers: Record> = { github } + if (config.google !== undefined) providers.google = google(config.google) + + const http = Http.make(HazelAuth, { + origin: config.origin, + ...(config.trustedOrigins === undefined ? {} : { trustedOrigins: config.trustedOrigins }), + cookie: { secure }, + oauth: { + providers, + // Registration UI belongs to Hazel. Only public correlation enters the URL; + // the registration credential and request binder stay in HttpOnly cookies. + respond: (value, { flowId }) => + Effect.gen(function* () { + const result = yield* Schema.decodeUnknownEffect( + HazelAuthApi.actions.completeSignIn.route.operation.rpc.successSchema, + )(value).pipe(Effect.orDie) + + const target = + "_tag" in result && result._tag === "RegistrationRequired" + ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` + : result.returnTarget + + return new Response(null, { status: 303, headers: { location: target } }) + }), + }, + }) + + return { http } +} + +/** Claims are read from Hazel's users table at session issuance. */ +const claimsFor = (subjectId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const rows = yield* sql<{ firstName: string; lastName: string }>` + select "firstName", "lastName" from users where id = ${subjectId}::uuid and "deletedAt" is null` + const row = rows[0] + if (row === undefined) return Option.none() + return Option.some({ displayName: `${row.firstName} ${row.lastName}`.trim() }) + }) + +const SocialClaimsLive = Layer.effect( + HazelAuth.strategies.social.SessionClaims, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return { + resolve: ({ subjectId }) => + claimsFor(subjectId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(OAuth.OAuthUnavailable.make({})), + onSome: Effect.succeed, + }), + ), + Effect.catchTag("SqlError", () => Effect.fail(OAuth.OAuthUnavailable.make({}))), + ), + } + }), +) + +const PasskeyClaimsLive = Layer.effect( + HazelAuth.strategies.passkey.SessionClaims, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return { + resolve: ({ subjectId }) => + claimsFor(subjectId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(Passkey.PasskeyUnavailable.make({})), + onSome: Effect.succeed, + }), + ), + Effect.catchTag("SqlError", () => Effect.fail(Passkey.PasskeyUnavailable.make({}))), + ), + } + }), +) + +export const makeHazelAuthLive = (config: HazelAuthConfig) => { + const { http } = makeHazelAuthHttp(config) + + // One "default" profile; validated by the library at layer build. + const passkeyConfig = Passkey.PasskeyConfig.layer({ + id: config.passkey.rpId, + name: "Hazel", + origins: config.passkey.origins, + developmentLocalhost: config.passkey.rpId === "localhost", + }) + + // Tables come from packages/db (drizzle-kit); nothing is created at runtime. + const Storage = Layer.mergeAll(Persistence.layer, OAuthStorageLive).pipe( + Layer.provideMerge(Persistence.Config.layer(storage)), + Layer.provideMerge(passkeyConfig), + ) + + const Services = Layer.mergeAll( + SocialClaimsLive, + PasskeyClaimsLive, + Auth.RequestBindingConfig.layer({ + generation: 1, + lifetimeMillis: 600_000, + keyring: config.keys.binding, + }), + OAuth.OAuthTransactionProtector.layer(config.keys.transaction), + OAuth.OAuthLinkTransactionProtector.layer(config.keys.transaction), + OAuth.OAuthReturnTargets.exactRoutes(config.returnTargets), + SimpleWebAuthn.layer, + ActionPoliciesLive, + ).pipe( + Layer.provideMerge(Storage), + // Postgres stamps session times with its own clock; tolerate it running slightly + // ahead of this process. Shared by Auth and persistence, so provided beneath both. + Layer.provideMerge(Operations.AuthenticationClock.layer({ futureToleranceMillis: 1_000 })), + Layer.provideMerge(layerRouted), + Layer.provideMerge(CryptoLive), + ) + + return { http, Services, sessionCookieName: sessionCookieName(config.origin) } +} diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts new file mode 100644 index 000000000..c8cda8333 --- /dev/null +++ b/packages/auth/src/yielded/sql.ts @@ -0,0 +1,89 @@ +import { PgClient } from "@effect/sql-pg" +import { Context, Effect, Layer, Option, Redacted, Scope } from "effect" +import { Reactivity } from "effect/reactivity" +import { SqlClient } from "effect/sql" +import type { SqlError } from "effect/sql/SqlError" + +/** + * The Postgres client auth storage runs on, chosen per request. + * + * The auth services are built once, but on Cloudflare Workers a socket belongs to the + * request that opened it, so they cannot hold a pool. Auth storage therefore uses a + * `SqlClient` that resolves this service on every statement: the Worker provides a + * lazily-connecting client per request, Bun provides one pool for the process. + */ +export class AuthSqlConnection extends Context.Service< + AuthSqlConnection, + { readonly client: Effect.Effect } +>()("@hazel/auth/AuthSqlConnection") {} + +/** + * Opens a dedicated connection for a statement that runs without an + * {@link AuthSqlConnection} in scope, closed when the statement's lease ends. + * + * The storage layers check the physical schema (columns, keys, join types) once, under + * the context they were built in rather than the request's. Where connections are per + * request (Workers), those few statements use this instead. + */ +export class AuthSqlDirect extends Context.Service< + AuthSqlDirect, + { readonly open: Effect.Effect } +>()("@hazel/auth/AuthSqlDirect") {} + +/** `SqlClient` for auth storage, routed to the current {@link AuthSqlConnection}. */ +export const layerRouted: Layer.Layer = Layer.effect( + SqlClient.SqlClient, + Effect.gen(function* () { + const direct = yield* Effect.serviceOption(AuthSqlDirect) + + const acquirer = Effect.serviceOption(AuthSqlConnection).pipe( + Effect.flatMap( + Option.match({ + onSome: (connection) => connection.client, + onNone: () => + Option.match(direct, { + onSome: (direct) => direct.open, + onNone: () => + Effect.die(new Error("AuthSqlConnection is not provided for this request")), + }), + }), + ), + // Each statement (and each transaction) leases a connection from that client. + Effect.flatMap((client) => client.reserve), + ) + + return yield* SqlClient.make({ + acquirer, + compiler: PgClient.makeCompiler(), + spanAttributes: [["db.system.name", "postgresql"]], + prepareTransactionControls: true, + }) + }), +).pipe(Layer.provide(Reactivity.layer)) + +/** One pooled client for a long-running process (Bun). */ +export const layerPool = (url: Redacted.Redacted) => + Layer.effect( + AuthSqlConnection, + PgClient.make({ url, maxConnections: 5 }).pipe( + Effect.map((client) => ({ client: Effect.succeed(client) })), + ), + ).pipe(Layer.provide(Reactivity.layer)) + +const connect = (url: Redacted.Redacted) => + PgClient.makeClient({ url }).pipe( + Effect.provide(Reactivity.layer), + Effect.map((client): SqlClient.SqlClient => client), + ) + +/** Direct connections for hosts that connect per request (Workers). */ +export const layerDirect = (url: Redacted.Redacted) => Layer.succeed(AuthSqlDirect, { open: connect(url) }) + +/** + * A client for one request that connects on its first statement, closed with `scope`. + * Requests that never touch auth storage open no connection. + */ +export const makeRequestConnection = (url: Redacted.Redacted, scope: Scope.Scope) => + Effect.cached(connect(url).pipe(Scope.provide(scope))).pipe( + Effect.map((client) => AuthSqlConnection.of({ client })), + ) diff --git a/packages/auth/src/yielded/storage.ts b/packages/auth/src/yielded/storage.ts new file mode 100644 index 000000000..97ec2d0bd --- /dev/null +++ b/packages/auth/src/yielded/storage.ts @@ -0,0 +1,251 @@ +import { OAuth, Schema as AuthSchema, Sessions } from "@yielded/auth" +import { AuthPersistence, PersistenceMappingError } from "@yielded/auth-persistence" +import type { SubjectIdCodec } from "@yielded/auth-persistence/Adapter" +import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" +import { Context, Crypto, Effect, Layer, Schema } from "effect" + +import { HazelAuth, requirement } from "./auth.ts" +import { Registration } from "./contract.ts" +import * as T from "./tables.ts" + +const { sql, eq } = Mapping + +/** Composed SQL storage for sessions, pending authentication and passkeys, on Hazel's + * `users` table. OAuth registration has no composed storage, so it is mapped below. */ +export const Persistence = AuthPersistence.make(HazelAuth) + +export const storage = Persistence.map({ + subjects: { + table: T.users, + id: "id", + status: "active", + activeValue: true, + securityRevision: "securityRevision", + idCodec: AuthSchema.SubjectId, + requirements: () => Effect.succeed(requirement), + }, + tables: { + identifiers: T.identifiers, + credentials: T.credentials, + sessions: T.sessions, + pending: T.pending, + passkeyCredentials: T.passkeyCredentials, + passkeyFlows: T.passkeyFlows, + }, +}) + +// ---- OAuth mappings ---- + +const subjectId: SubjectIdCodec = { + toNative: (id) => Effect.succeed(id), + toSubject: (id) => + Schema.decodeEffect(AuthSchema.SubjectId)(id).pipe( + Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.subject", cause })), + ), + equals: (left, right) => left === right, +} + +const subject = { + table: T.oauthUsers, + id: "id", + status: "status", + securityRevision: "securityRevision", + isActiveStatus: (value: unknown) => value === true, + activeCondition: eq(T.oauthUsers.columns.status, true), + decodeAuthenticationRequirement: () => requirement, + nextSecurityRevision: (current) => + Sessions.SecurityRevision.make(current === "initial" ? "1" : String(BigInt(current) + 1n)), +} satisfies Mapping.OAuthSubjectTable + +const authority = { + table: T.oauthAuthority, + subjectId: "subjectId", + credentialId: "credentialId", + revision: "revision", + status: "status", + isActiveStatus: (value: unknown) => value === true, + activeCondition: eq(T.oauthAuthority.columns.status, true), + encodeInsert: (value) => ({ ...value, status: true }), +} satisfies Mapping.OAuthAuthorityTable + +const credential = { + table: T.oauthLogins, + moduleId: "moduleId", + credentialId: "credentialId", + subjectId: "subjectId", + identityKey: "identityKey", + credentialRevision: "credentialRevision", + status: "status", + isActiveStatus: (value: unknown) => value === "active", + activeCondition: eq(T.oauthLogins.columns.status, "active"), + removal: "delete", + encodeInsert: (value) => ({ ...value, status: "active" }), +} satisfies Mapping.OAuthCredentialTable + +const ownership = { + table: T.oauthIdentities, + identityKey: "identityKey", + provider: "provider", + issuer: "issuer", + externalSubject: "externalSubject", + subjectId: "subjectId", + decodeSubjectId: (row) => Schema.decodeUnknownSync(Schema.String)(row.subjectId), + encodeInsert: ({ identityKey, identity, subjectId }) => ({ + identityKey, + provider: identity.provider, + issuer: identity.issuer, + externalSubject: identity.subject, + subjectId, + }), +} satisfies Mapping.OAuthOwnershipTable + +const flow = { + table: T.oauthFlows, + moduleId: "moduleId", + flowId: "flowId", + purpose: "purpose", + generation: "generation", + provider: "provider", + callbackId: "callbackId", + issuer: "issuer", + responseIssuerMode: "responseIssuerMode", + subjectId: "subjectId", + stateDigest: "stateDigest", + binderVerifier: "binderVerifier", + binderExpiresAt: "binderExpiresAt", + snapshot: "snapshot", + issuedAt: "issuedAt", + expiresAt: "expiresAt", + encodeInsert: (input) => input, +} satisfies Mapping.OAuthFlowTable + +const intent = { + table: T.oauthRegistrations, + moduleId: "moduleId", + reference: "reference", + flowId: "flowId", + identityKey: "identityKey", + snapshot: "snapshot", + expiresAt: "expiresAt", + retentionUntil: "retentionUntil", + encodeInsert: (value) => ({ + moduleId: value.context.moduleId, + reference: value.reference, + flowId: value.context.flowId, + }), +} satisfies Mapping.OAuthRegistrationIntentTable + +const registrationJson = Schema.fromJsonString(Registration) + +/** Provider-verified email; GitHub omits it when the user's email is private. */ +const emailFromIntent = (intent: OAuth.OAuthRegistrationIntent) => + intent.profile?.email ?? `${intent.identity.subject}@${intent.identity.provider}.invalid` + +export const OAuthStorageLive = Layer.effectContext( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto + + const uuid = crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.allocate", cause })), + ) + + const common = { subject, authority, subjectId, clock: Mapping.clock } + + const signIn = yield* Mapping.makeOAuthSignInServices({ + ...common, + ownership, + credential, + flow, + constraints: Mapping.requiredOAuthSignInConstraints, + }) + + const intents = yield* Mapping.makeOAuthRegistrationIntentServices({ + ownership, + intent, + clock: Mapping.clock, + constraints: { + intentReference: Mapping.requiredOAuthRegistrationConstraints.intentReference, + intentFlow: Mapping.requiredOAuthRegistrationConstraints.intentFlow, + ownership: Mapping.requiredOAuthRegistrationConstraints.ownership, + }, + eligible: () => sql`true`, + }) + + const registration = yield* Mapping.makeOAuthRegistrationServices({ + registration: Registration, + ownership, + intent, + subject, + credential, + authority, + subjectId, + clock: Mapping.clock, + constraints: Mapping.requiredOAuthRegistrationConstraints, + inspect: ({ registration }) => + Effect.succeed({ + fingerprint: `hazel-v1:${Schema.encodeSync(registrationJson)(registration)}`, + eligible: true, + }), + eligibility: { admission: () => sql`true`, postcondition: () => sql`true` }, + allocateSubjectId: uuid, + allocateCredentialId: uuid, + allocateRevision: Effect.succeed(Sessions.SecurityRevision.make("initial")), + encodeSubjectInsert: ({ intent, registration }, ids) => ({ + id: ids.subjectId, + // externalId is the Clerk user id for users who signed up through Clerk; it stays + // NOT NULL and unique, so users created here get a value in their own namespace. + externalId: `hazel:${ids.subjectId}`, + status: true, + securityRevision: ids.securityRevision, + email: emailFromIntent(intent), + firstName: registration.firstName, + lastName: registration.lastName, + avatarUrl: intent.profile?.avatarUrl ?? null, + }), + }) + + const accounts = yield* Mapping.makeOAuthAccountsServices({ + ...common, + ownership, + credential, + flow, + constraints: Mapping.requiredOAuthSignInConstraints, + metadataAccess: ({ invocation, subjectId }) => + invocation._tag === "Authenticated" && invocation.subjectId === subjectId + ? sql`true` + : sql`false`, + eligibility: [ + Mapping.oauthEligibilityTable({ + table: T.oauthLogins, + subjectId: "subjectId", + credentialId: "credentialId", + revision: "credentialRevision", + scope: "hazel-oauth-logins", + condition: () => eq(T.oauthLogins.columns.status, "active"), + decode: (row) => ({ + credentialId: Schema.decodeUnknownSync(Schema.String)(row.credentialId), + revision: Schema.decodeUnknownSync(Sessions.SecurityRevision)(row.credentialRevision), + usablePrimary: row.status === "active", + factors: ["possession"], + userVerified: false, + phishingResistant: false, + }), + }), + ], + cleanup: [], + sessionInvalidation: "same-authority-immediate", + otherReferences: () => sql`false`, + allocateCredentialId: uuid, + allocateRevision: uuid.pipe(Effect.map((value) => Sessions.SecurityRevision.make(value))), + }) + + return Context.make(OAuth.OAuthSignInPersistence, signIn.oauthSignInPersistence).pipe( + Context.add(OAuth.OAuthRegistrationIntents, intents.oauthRegistrationIntents), + Context.add(OAuth.OAuthAccountsPersistence, accounts.oauthAccountsPersistence), + Context.add( + HazelAuth.strategies.social.registration.RegistrationAuthority, + registration.registrationAuthority, + ), + ) + }), +) diff --git a/packages/auth/src/yielded/tables.ts b/packages/auth/src/yielded/tables.ts new file mode 100644 index 000000000..bee20c976 --- /dev/null +++ b/packages/auth/src/yielded/tables.ts @@ -0,0 +1,213 @@ +import { AuthPersistence } from "@yielded/auth-persistence" +import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" + +/** + * The library's view of Hazel's auth tables. The physical tables are the Drizzle schema in + * `packages/db/src/schema/auth.ts` (plus `users`); keep names and unique keys in step. + */ + +// ---- AuthPersistence (sessions, pending authentication, passkeys) ---- + +export const users = AuthPersistence.table({ + name: "users", + columns: { + id: { name: "id", type: "text" }, + active: { name: "authActive", type: "boolean" }, + securityRevision: { name: "securityRevision", type: "text" }, + }, + unique: [["id"]], +}) + +export const identifiers = AuthPersistence.table({ + name: "auth_identifiers", + columns: { + moduleId: { name: "moduleId", type: "text", nullable: true }, + credentialId: { name: "credentialId", type: "text", nullable: true }, + namespace: { name: "namespace", type: "text" }, + value: { name: "value", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + revision: { name: "revision", type: "text" }, + verifiedAt: { name: "verifiedAt", type: "integer", nullable: true }, + active: { name: "active", type: "boolean" }, + }, + unique: [["namespace", "value"]], +}) + +export const credentials = AuthPersistence.table({ + name: "auth_credentials", + columns: { + credentialId: { name: "credentialId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + revision: { name: "revision", type: "text" }, + active: { name: "active", type: "boolean" }, + }, + unique: [["credentialId"]], +}) + +export const sessions = AuthPersistence.table({ + name: "auth_sessions", + columns: { + sessionId: { name: "sessionId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + digest: { name: "digest", type: "text" }, + securityRevision: { name: "securityRevision", type: "text" }, + issuedAt: { name: "issuedAt", type: "integer" }, + expiresAt: { name: "expiresAt", type: "integer" }, + absoluteExpiresAt: { name: "absoluteExpiresAt", type: "integer" }, + record: { name: "record", type: "text" }, + }, + unique: [["sessionId"], ["digest"]], +}) + +export const pending = AuthPersistence.table({ + name: "auth_pending", + columns: { + moduleId: { name: "moduleId", type: "text" }, + kind: { name: "kind", type: "text" }, + digest: { name: "digest", type: "text" }, + version: { name: "version", type: "text" }, + flowId: { name: "flowId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + bindingDigest: { name: "bindingDigest", type: "text" }, + snapshot: { name: "snapshot", type: "text" }, + expiresAt: { name: "expiresAt", type: "integer" }, + attemptLimit: { name: "attemptLimit", type: "integer" }, + failedAttempts: { name: "failedAttempts", type: "integer" }, + consumed: { name: "consumed", type: "boolean" }, + }, + unique: [["digest"]], +}) + +export const passkeyCredentials = AuthPersistence.table({ + name: "auth_passkey_credentials", + columns: { + credentialId: { name: "credentialId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + rpId: { name: "rpId", type: "text" }, + protocolCredentialId: { name: "protocolCredentialId", type: "text" }, + credentialKey: { name: "credentialKey", type: "text" }, + userHandle: { name: "userHandle", type: "text" }, + publicKey: { name: "publicKey", type: "text" }, + algorithm: { name: "algorithm", type: "integer" }, + profile: { name: "profile", type: "text" }, + credentialRevision: { name: "credentialRevision", type: "text" }, + active: { name: "active", type: "boolean" }, + primarySignIn: { name: "primarySignIn", type: "boolean" }, + enrollmentUserVerified: { name: "enrollmentUserVerified", type: "boolean" }, + backupEligible: { name: "backupEligible", type: "boolean" }, + backupState: { name: "backupState", type: "boolean" }, + counter: { name: "counter", type: "integer" }, + name: { name: "name", type: "text" }, + createdAt: { name: "createdAt", type: "integer" }, + lastUsedAt: { name: "lastUsedAt", type: "integer", nullable: true }, + }, + unique: [["credentialId"], ["credentialKey"]], +}) + +export const passkeyFlows = AuthPersistence.table({ + name: "auth_passkey_flows", + columns: { + moduleId: { name: "moduleId", type: "text" }, + flowId: { name: "flowId", type: "text" }, + purpose: { name: "purpose", type: "text" }, + snapshot: { name: "snapshot", type: "text" }, + requestBindingVerifier: { name: "requestBindingVerifier", type: "text" }, + requestBindingExpiresAt: { name: "requestBindingExpiresAt", type: "integer" }, + issuedAt: { name: "issuedAt", type: "integer" }, + expiresAt: { name: "expiresAt", type: "integer" }, + }, + unique: [["moduleId", "flowId"]], +}) + +// ---- OAuthPersistence (provider identities, login credentials, flows) ---- + +export const oauthUsers = Mapping.table({ + name: "users", + columns: { + id: { name: "id", type: "text" }, + status: { name: "authActive", type: "boolean" }, + securityRevision: { name: "securityRevision", type: "text" }, + // Application columns written when OAuth registration provisions a user. + externalId: { name: "externalId", type: "text" }, + email: { name: "email", type: "text" }, + firstName: { name: "firstName", type: "text" }, + lastName: { name: "lastName", type: "text" }, + avatarUrl: { name: "avatarUrl", type: "text", nullable: true }, + }, + unique: [["id"]], +}) + +export const oauthAuthority = Mapping.table({ + name: "auth_credentials", + columns: { + subjectId: { name: "subjectId", type: "text" }, + credentialId: { name: "credentialId", type: "text" }, + revision: { name: "revision", type: "text" }, + status: { name: "active", type: "boolean" }, + }, + unique: [["subjectId", "credentialId"]], +}) + +export const oauthIdentities = Mapping.table({ + name: "auth_oauth_identities", + columns: { + identityKey: { name: "identityKey", type: "text" }, + provider: { name: "provider", type: "text" }, + issuer: { name: "issuer", type: "text" }, + externalSubject: { name: "externalSubject", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + }, + unique: [["identityKey"]], +}) + +export const oauthLogins = Mapping.table({ + name: "auth_oauth_logins", + columns: { + moduleId: { name: "moduleId", type: "text" }, + credentialId: { name: "credentialId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + identityKey: { name: "identityKey", type: "text" }, + credentialRevision: { name: "credentialRevision", type: "text" }, + status: { name: "status", type: "text" }, + }, + unique: [["credentialId"], ["identityKey"]], +}) + +export const oauthFlows = Mapping.table({ + name: "auth_oauth_flows", + columns: { + moduleId: { name: "moduleId", type: "text" }, + flowId: { name: "flowId", type: "text" }, + purpose: { name: "purpose", type: "text" }, + generation: { name: "generation", type: "integer", nullable: true }, + provider: { name: "provider", type: "text", nullable: true }, + callbackId: { name: "callbackId", type: "text", nullable: true }, + issuer: { name: "issuer", type: "text", nullable: true }, + responseIssuerMode: { name: "responseIssuerMode", type: "text", nullable: true }, + subjectId: { name: "subjectId", type: "text", nullable: true }, + stateDigest: { name: "stateDigest", type: "text", nullable: true }, + binderVerifier: { name: "binderVerifier", type: "text", nullable: true }, + binderExpiresAt: { name: "binderExpiresAt", type: "integer", nullable: true }, + snapshot: { name: "snapshot", type: "text", nullable: true }, + issuedAt: { name: "issuedAt", type: "integer", nullable: true }, + expiresAt: { name: "expiresAt", type: "integer", nullable: true }, + }, + unique: [["moduleId", "flowId"], ["stateDigest"]], +}) + +export const oauthRegistrations = Mapping.table({ + name: "auth_oauth_registrations", + columns: { + moduleId: { name: "moduleId", type: "text" }, + reference: { name: "reference", type: "text" }, + flowId: { name: "flowId", type: "text" }, + identityKey: { name: "identityKey", type: "text", nullable: true }, + snapshot: { name: "snapshot", type: "text", nullable: true }, + expiresAt: { name: "expiresAt", type: "integer", nullable: true }, + retentionUntil: { name: "retentionUntil", type: "integer", nullable: true }, + }, + unique: [ + ["moduleId", "reference"], + ["moduleId", "flowId"], + ], +}) diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts new file mode 100644 index 000000000..8a51f7e8a --- /dev/null +++ b/packages/db/src/schema/auth.ts @@ -0,0 +1,185 @@ +import type { UserId } from "@hazel/schema" +import { bigint, boolean, index, pgTable, text, unique, uuid } from "drizzle-orm/pg-core" + +/** + * Storage for Hazel's own authentication (`@hazel/auth/server`, built on `@yielded/auth`). + * `users` is the auth subject (its `authActive` and `securityRevision` columns live in + * users.ts); these tables hold sessions, pending sign-ins, passkeys and OAuth identities. + * + * Column names, types and unique constraints are read by the library's SQL mappings in + * `packages/auth/src/yielded/tables.ts`, so change both together. Times are epoch + * milliseconds. `subjectId` columns are uuid so they join `users.id` directly. + */ + +const millis = (name?: string) => + name === undefined ? bigint({ mode: "number" }) : bigint(name, { mode: "number" }) +const subjectId = () => uuid().$type() + +/** Credentials that make up a user's current security revision (one row per login). */ +export const authCredentialsTable = pgTable( + "auth_credentials", + { + credentialId: text().notNull().unique(), + subjectId: subjectId().notNull(), + revision: text().notNull(), + active: boolean().notNull(), + }, + (table) => [unique("auth_credentials_subject_credential").on(table.subjectId, table.credentialId)], +) + +/** Verified identifiers (unused while sign-in is OAuth and passkeys only). */ +export const authIdentifiersTable = pgTable( + "auth_identifiers", + { + moduleId: text(), + credentialId: text(), + namespace: text().notNull(), + value: text().notNull(), + subjectId: subjectId().notNull(), + revision: text().notNull(), + verifiedAt: millis(), + active: boolean().notNull(), + }, + (table) => [unique("auth_identifiers_namespace_value").on(table.namespace, table.value)], +) + +export const authSessionsTable = pgTable( + "auth_sessions", + { + sessionId: text().notNull().unique(), + subjectId: subjectId().notNull(), + digest: text().notNull().unique(), + securityRevision: text().notNull(), + issuedAt: millis().notNull(), + expiresAt: millis().notNull(), + absoluteExpiresAt: millis().notNull(), + record: text().notNull(), + }, + (table) => [index("auth_sessions_subject_idx").on(table.subjectId)], +) + +/** Sign-ins waiting on a further step. */ +export const authPendingTable = pgTable("auth_pending", { + moduleId: text().notNull(), + kind: text().notNull(), + digest: text().notNull().unique(), + version: text().notNull(), + flowId: text().notNull(), + subjectId: subjectId().notNull(), + bindingDigest: text().notNull(), + snapshot: text().notNull(), + expiresAt: millis().notNull(), + attemptLimit: millis().notNull(), + failedAttempts: millis().notNull(), + consumed: boolean().notNull(), +}) + +export const authPasskeyCredentialsTable = pgTable( + "auth_passkey_credentials", + { + credentialId: text().notNull().unique(), + subjectId: subjectId().notNull(), + rpId: text().notNull(), + protocolCredentialId: text().notNull(), + credentialKey: text().notNull().unique(), + userHandle: text().notNull(), + publicKey: text().notNull(), + algorithm: millis().notNull(), + profile: text().notNull(), + credentialRevision: text().notNull(), + active: boolean().notNull(), + primarySignIn: boolean().notNull(), + enrollmentUserVerified: boolean().notNull(), + backupEligible: boolean().notNull(), + backupState: boolean().notNull(), + counter: millis().notNull(), + name: text().notNull(), + createdAt: millis().notNull(), + lastUsedAt: millis(), + }, + (table) => [index("auth_passkey_credentials_subject_idx").on(table.subjectId)], +) + +/** Passkey ceremonies in progress. */ +export const authPasskeyFlowsTable = pgTable( + "auth_passkey_flows", + { + moduleId: text().notNull(), + flowId: text().notNull(), + purpose: text().notNull(), + snapshot: text().notNull(), + requestBindingVerifier: text().notNull(), + requestBindingExpiresAt: millis().notNull(), + issuedAt: millis().notNull(), + expiresAt: millis().notNull(), + }, + (table) => [unique("auth_passkey_flows_module_flow").on(table.moduleId, table.flowId)], +) + +/** Which Hazel user owns each provider account (GitHub user id, Google `sub`). */ +export const authOAuthIdentitiesTable = pgTable( + "auth_oauth_identities", + { + identityKey: text().primaryKey(), + provider: text().notNull(), + issuer: text().notNull(), + externalSubject: text().notNull(), + subjectId: subjectId().notNull(), + }, + (table) => [index("auth_oauth_identities_subject_idx").on(table.subjectId)], +) + +/** Provider accounts usable for sign-in. */ +export const authOAuthLoginsTable = pgTable( + "auth_oauth_logins", + { + moduleId: text().notNull(), + credentialId: text().primaryKey(), + subjectId: subjectId().notNull(), + identityKey: text().notNull().unique(), + credentialRevision: text().notNull(), + status: text().notNull(), + }, + (table) => [index("auth_oauth_logins_owner_idx").on(table.identityKey, table.subjectId)], +) + +/** OAuth redirects in progress. */ +export const authOAuthFlowsTable = pgTable( + "auth_oauth_flows", + { + moduleId: text().notNull(), + flowId: text().notNull(), + purpose: text().notNull(), + generation: millis(), + provider: text(), + callbackId: text(), + issuer: text(), + responseIssuerMode: text(), + subjectId: subjectId(), + stateDigest: text().unique(), + binderVerifier: text(), + binderExpiresAt: millis(), + snapshot: text(), + issuedAt: millis(), + expiresAt: millis(), + }, + (table) => [unique("auth_oauth_flows_module_flow").on(table.moduleId, table.flowId)], +) + +/** First-time provider sign-ins waiting for the registration form. */ +export const authOAuthRegistrationsTable = pgTable( + "auth_oauth_registrations", + { + moduleId: text().notNull(), + reference: text().notNull(), + flowId: text().notNull(), + identityKey: text(), + snapshot: text(), + expiresAt: millis(), + retentionUntil: millis(), + }, + (table) => [ + unique("auth_oauth_registrations_module_reference").on(table.moduleId, table.reference), + unique("auth_oauth_registrations_module_flow").on(table.moduleId, table.flowId), + ], +) diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c6489c749..cf88b96f0 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,6 +2,7 @@ export * from "@hazel/schema" export * from "./attachments" +export * from "./auth" export * from "./bot-commands" export * from "./bot-installations" export * from "./bots" diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts index aac30729a..1c535e07f 100644 --- a/packages/db/src/schema/users.ts +++ b/packages/db/src/schema/users.ts @@ -19,6 +19,10 @@ export const usersTable = pgTable( createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), deletedAt: timestamp({ mode: "date", withTimezone: true }), + // Hazel auth subject state (see auth.ts). `authActive` false blocks sign-in and ends + // sessions; `securityRevision` changes when the user's sign-in methods change. + authActive: boolean().notNull().default(true), + securityRevision: text().notNull().default("initial"), }, (table) => [ index("users_external_id_idx").on(table.externalId), From 5e35d258ce286b1173b35d5239ab0d0522dd4840 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 10 Oct 2026 23:57:57 +0200 Subject: [PATCH 02/10] feat(backend): serve Hazel sign-in alongside Clerk Mounts /auth/* (GitHub, Google, passkeys, sessions), /auth/token and /.well-known/jwks.json when AUTH_GITHUB_CLIENT_ID is set. The RPC and HttpApi auth middleware accept the Hazel session cookie (trusted Origin required) or the session credential as a bearer token, before falling back to Clerk JWTs and bot tokens. Auth storage gets a pooled Postgres client on Bun and a lazily-connecting one per request on the Worker. Co-Authored-By: Claude Opus 5.5 --- apps/backend/.env.example | 13 + apps/backend/package.json | 1 + apps/backend/src/app.ts | 31 ++- apps/backend/src/index.ts | 10 +- apps/backend/src/rpc/middleware/auth.test.ts | 61 ++++ apps/backend/src/rpc/middleware/auth.ts | 21 +- apps/backend/src/services/auth.ts | 35 ++- apps/backend/src/services/hazel-auth.test.ts | 278 +++++++++++++++++++ apps/backend/src/services/hazel-auth.ts | 167 +++++++++++ apps/backend/src/services/hazel-session.ts | 116 ++++++++ apps/backend/src/worker/env.ts | 13 + apps/backend/src/worker/platform.ts | 34 ++- bun.lock | 1 + packages/auth/package.json | 7 +- packages/auth/scripts/generate-keys.ts | 22 ++ packages/auth/src/yielded/access-token.ts | 38 ++- packages/auth/src/yielded/hazel-auth.test.ts | 27 +- packages/auth/src/yielded/server.ts | 14 +- packages/auth/src/yielded/sql.ts | 10 +- 19 files changed, 856 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/services/hazel-auth.test.ts create mode 100644 apps/backend/src/services/hazel-auth.ts create mode 100644 apps/backend/src/services/hazel-session.ts create mode 100644 packages/auth/scripts/generate-keys.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 0b1237d0a..26c5ba5c6 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -15,6 +15,19 @@ CLERK_SECRET_KEY=sk_test_your_clerk_secret_here CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_here CLERK_WEBHOOK_SECRET=whsec_your_clerk_webhook_secret_here +# Hazel sign-in (replacing Clerk). Off until AUTH_GITHUB_CLIENT_ID is set. +# GitHub OAuth App callback URL: {API_BASE_URL}/auth/github/callback +# Google OAuth client redirect URI: {API_BASE_URL}/auth/google/callback +# Generate the keys with: bun run --cwd packages/auth generate-keys +# AUTH_GITHUB_CLIENT_ID= +# AUTH_GITHUB_CLIENT_SECRET= +# AUTH_GOOGLE_CLIENT_ID= +# AUTH_GOOGLE_CLIENT_SECRET= +# AUTH_BINDING_KEY= +# AUTH_TRANSACTION_KEY= +# AUTH_ACCESS_TOKEN_KID= +# AUTH_ACCESS_TOKEN_PRIVATE_JWK= + # S3-Compatible Storage (e.g., Cloudflare R2) # Bun auto-reads these S3_* env vars (or AWS_* as fallback) # Get these from your S3-compatible storage provider dashboard diff --git a/apps/backend/package.json b/apps/backend/package.json index 36f2332f8..487ab3a57 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,6 +38,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "@yielded/auth": "0.1.0-beta.32", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 545ca14bb..73e585a5e 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -76,6 +76,8 @@ import { MockDataGenerator } from "./services/mock-data-generator" import { OAuthBearerAuth } from "./services/oauth-bearer-auth" import { ObjectStorage } from "./services/object-storage" import { OAuthProviderRegistry } from "./services/oauth" +import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./services/hazel-auth" +import { HazelSession } from "./services/hazel-session" import { SessionManager } from "./services/session-manager" import { WebhookBotService } from "./services/webhook-bot-service" import { BotGatewayService } from "./services/bot-gateway-service" @@ -103,7 +105,13 @@ const RpcRoute = RpcServer.layerHttp({ protocol: "http", }).pipe(Layer.provide(RpcSerialization.layerNdjson), Layer.provide(RpcServerLive)) -export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe( +export const AllRoutes = Layer.mergeAll( + HttpApiRoutes, + HealthRouter, + DocsRoute, + RpcRoute, + HazelAuthRoutes, +).pipe( Layer.provide( HttpRouter.cors({ allowedOrigins: [ @@ -119,6 +127,16 @@ export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, ), ) +/** + * Hazel's own sign-in: the configured instance, its auth services, and the session lookup + * the RPC and HttpApi middleware use. Inert until `AUTH_GITHUB_CLIENT_ID` is set. Needs + * `AuthSqlConnection` (and `AuthSqlDirect` on Workers) from the entry point. + */ +export const HazelAuthLive = HazelSession.layer.pipe( + Layer.provideMerge(HazelAuthServicesLive), + Layer.provideMerge(HazelAuthInstance.layer), +) + export const RepoLive = Layer.mergeAll( MessageRepo.layer, ChannelRepo.layer, @@ -204,5 +222,12 @@ export const AppServicesLive = Layer.mergeAll( OAuthBearerAuth.layer, ).pipe(Layer.provideMerge(FetchHttpClient.layer)) -/** `CurrentUser` resolution for authenticated routes. Requires `Database` from the platform. */ -export const AppAuthorizationLive = AuthorizationLive.pipe(Layer.provideMerge(SessionManager.layer)) +/** + * `CurrentUser` resolution for authenticated routes, and Hazel's sign-in services. Only the + * HTTP entry points build this; the Durable Objects never authenticate. Requires `Database` + * and the auth SQL connection from the platform. + */ +export const AppAuthorizationLive = AuthorizationLive.pipe( + Layer.provideMerge(SessionManager.layer), + Layer.provideMerge(HazelAuthLive), +) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 6809cee28..8f6b456a3 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,13 +3,15 @@ * `worker.ts`. See infra/cloudflare-migration-plan.md. */ import { BunHttpServer, BunRuntime, BunSocket } from "@effect/platform-bun" +import { layerPool as authSqlPool } from "@hazel/auth/server" import { Redis, RedisResultPersistenceLive } from "@hazel/effect-bun" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" -import { Config, ConfigProvider, Layer } from "effect" +import { Config, ConfigProvider, Effect, Layer } from "effect" import { HttpMiddleware, HttpRouter } from "effect/http" import { AllRoutes, AppAuthorizationLive, AppServicesLive, HazelApi } from "./app" import { BotGatewayTransport } from "./services/bot-gateway-transport" import { DiscordGatewayBackgroundLive } from "./services/chat-sync/discord-gateway-service" +import { EnvVars } from "./lib/env-vars" import { DatabaseLive } from "./services/database" import { MessageOutboxDispatcher } from "./services/message-outbox-dispatcher" import { RateLimiterRedisLive } from "./services/rate-limiter-redis" @@ -24,12 +26,18 @@ const TracerLive = createTracingLayer("api") // ResultPersistence layer for session caching (uses Redis backing) const PersistenceLive = RedisResultPersistenceLive.pipe(Layer.provide(Redis.Default)) +/** Auth storage's own Postgres pool (Effect SQL; the rest of the backend uses Drizzle). */ +const AuthSqlLive = Layer.unwrap( + Effect.map(Effect.service(EnvVars), (env) => authSqlPool(env.DATABASE_URL, { ssl: !env.IS_DEV })), +).pipe(Layer.provide(EnvVars.layer)) + /** * Bun's platform services: a pooled database, Redis-backed caches and rate limits, and bot gateway * events appended to the Durable Streams server the Bun bot gateway reads. */ const PlatformLive = Layer.mergeAll( DatabaseLive, + AuthSqlLive, PersistenceLive, Redis.Default, RateLimiterRedisLive, diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index ba48e7d30..219e50ac7 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -6,6 +6,7 @@ import { CurrentUser, type CurrentUser as CurrentUserNamespace } from "@hazel/do import type { UserId } from "@hazel/schema" import { Effect, Layer, Option, Ref, Result, Context } from "effect" import { AuthMiddleware, AuthMiddlewareLive } from "./auth.ts" +import { HazelSession } from "../../services/hazel-session.ts" import { SessionManager } from "../../services/session-manager.ts" import { serviceShape } from "../../test/effect-helpers" @@ -73,6 +74,19 @@ const makeSessionManagerLayer = (currentUser: CurrentUserNamespace.Schema) => }), ) +type HazelSessionShape = Context.Service.Shape + +const makeHazelSessionLayer = (overrides: Partial = {}) => + Layer.succeed( + HazelSession, + serviceShape({ + enabled: false, + verify: () => Effect.succeedNone, + fromCookie: () => Effect.succeedNone, + ...overrides, + }), + ) + const makeBotRepoLayer = (findByTokenHash: BotRepoShape["findByTokenHash"]) => Layer.succeed( BotRepo, @@ -119,6 +133,8 @@ const USER_RECORD: UserRecord = { settings: null, isOnboarded: true, timezone: "UTC", + authActive: true, + securityRevision: "initial", createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), deletedAt: null, @@ -128,6 +144,7 @@ const runAuth = ( headers: Headers.Headers, overrides: { sessionManager?: ReturnType + hazelSession?: ReturnType botRepo?: ReturnType userRepo?: ReturnType } = {}, @@ -137,6 +154,7 @@ const runAuth = ( invokeMiddleware(headers).pipe( Effect.provide(AuthMiddlewareLive), Effect.provide(overrides.sessionManager ?? makeSessionManagerLayer(makeCurrentUser())), + Effect.provide(overrides.hazelSession ?? makeHazelSessionLayer()), Effect.provide( overrides.botRepo ?? makeBotRepoLayer(() => Effect.succeed(Option.none())), ), @@ -215,4 +233,47 @@ describe("AuthMiddlewareLive", () => { expect(failureTag).toBe("InvalidBearerTokenError") } }) + + it("authenticates a Hazel session cookie before any bearer token", async () => { + const currentUser = makeCurrentUser({ email: "cookie@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + fromCookie: () => Effect.succeed(Option.some(currentUser)), + }), + }) + + expect(Result.isSuccess(result) && Option.getOrNull(result.success)?.email).toBe("cookie@example.com") + }) + + it("authenticates a Hazel session credential sent as a bearer token", async () => { + const currentUser = makeCurrentUser({ email: "native@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer session-credential" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + verify: (credential) => + Effect.succeed( + credential === "session-credential" ? Option.some(currentUser) : Option.none(), + ), + }), + }) + + expect(Result.isSuccess(result) && Option.getOrNull(result.success)?.email).toBe("native@example.com") + }) + + it("never checks bot tokens as Hazel sessions", async () => { + let checked = false + const result = await runAuth(Headers.fromInput({ authorization: "Bearer hzl_bot_abc" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + verify: () => { + checked = true + return Effect.succeedNone + }, + }), + }) + + expect(checked).toBe(false) + expect(Result.isFailure(result)).toBe(true) + }) }) diff --git a/apps/backend/src/rpc/middleware/auth.ts b/apps/backend/src/rpc/middleware/auth.ts index f776aefb1..0a9c006f0 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -4,6 +4,7 @@ import { CurrentUser, InvalidBearerTokenError, SessionNotProvidedError } from "@ import { Effect, Layer, Option } from "effect" import { AuthMiddleware } from "@hazel/domain/rpc" import { type ApiScope, CurrentBotScopes } from "@hazel/domain/scopes" +import { HazelSession } from "../../services/hazel-session" import { SessionManager } from "../../services/session-manager" export { AuthMiddleware } from "@hazel/domain/rpc" @@ -31,12 +32,19 @@ export const AuthMiddlewareLive = Layer.effect( AuthMiddleware, Effect.gen(function* () { const sessionManager = yield* SessionManager + const hazelSession = yield* HazelSession const botRepo = yield* BotRepo const userRepo = yield* UserRepo return AuthMiddleware.of((effect, { headers }) => Effect.gen(function* () { - // Check for Bearer token first (bot SDK or desktop app authentication) + // Hazel session cookie (browser). RPC calls are POSTs, so the Origin is checked. + const cookieUser = yield* hazelSession.fromCookie(headers, "POST") + if (Option.isSome(cookieUser)) { + return yield* Effect.provideService(effect, CurrentUser.Context, cookieUser.value) + } + + // Bearer token: Clerk JWT, Hazel session credential (native clients) or bot token const authHeader = Headers.get(headers, "authorization") if (Option.isSome(authHeader) && authHeader.value.startsWith("Bearer ")) { @@ -48,6 +56,17 @@ export const AuthMiddlewareLive = Layer.effect( return yield* Effect.provideService(effect, CurrentUser.Context, currentUser) } + if (hazelSession.enabled && !token.startsWith("hzl_bot_")) { + const sessionUser = yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + effect, + CurrentUser.Context, + sessionUser.value, + ) + } + } + // Otherwise, treat as bot token (hash-based lookup) const tokenHash = yield* Effect.promise(() => hashToken(token)) diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index 14f4c172b..2907b553e 100644 --- a/apps/backend/src/services/auth.ts +++ b/apps/backend/src/services/auth.ts @@ -1,21 +1,48 @@ import { CurrentUser } from "@hazel/domain" -import { Effect, Layer, Redacted } from "effect" +import { Effect, Layer, Option, Redacted } from "effect" +import { HttpServerRequest } from "effect/http" +import { HazelSession } from "./hazel-session" import { SessionManager } from "./session-manager" +/** Three base64url segments: a Clerk session JWT rather than a Hazel session credential. */ +const isJwt = (token: string) => { + const parts = token.split(".") + return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)) +} + export const AuthorizationLive = Layer.effect( CurrentUser.Authorization, Effect.gen(function* () { yield* Effect.logDebug("Initializing Authorization middleware...") const sessionManager = yield* SessionManager + const hazelSession = yield* HazelSession return CurrentUser.Authorization.of({ bearer: (httpEffect, { credential: bearerToken }) => Effect.gen(function* () { - yield* Effect.logDebug("checking bearer token") + // Hazel session cookie (browser) + const request = yield* HttpServerRequest.HttpServerRequest + const cookieUser = yield* hazelSession.fromCookie(request.headers, request.method) + if (Option.isSome(cookieUser)) { + return yield* Effect.provideService(httpEffect, CurrentUser.Context, cookieUser.value) + } + + // Hazel session credential (native clients) + const token = Redacted.value(bearerToken) + if (hazelSession.enabled && token !== "" && !isJwt(token)) { + const sessionUser = yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + httpEffect, + CurrentUser.Context, + sessionUser.value, + ) + } + } - // Use SessionManager to handle bearer token authentication - const user = yield* sessionManager.authenticateWithBearer(Redacted.value(bearerToken)) + // Clerk session JWT + const user = yield* sessionManager.authenticateWithBearer(token) return yield* Effect.provideService(httpEffect, CurrentUser.Context, user) }), }) diff --git a/apps/backend/src/services/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts new file mode 100644 index 000000000..5857d739d --- /dev/null +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -0,0 +1,278 @@ +/** + * The backend's Hazel sign-in wiring end to end: env config, the `/auth/*` and token + * routes, and both auth middlewares (RPC and HttpApi) resolving the session cookie to the + * Hazel user. Runs against a disposable Postgres container with the schema pushed from + * packages/db. Only GitHub is simulated. + */ +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { AccessToken, CryptoLive, layerPool } from "@hazel/auth/server" +import { BotRepo, UserRepo } from "@hazel/backend-core" +import { CurrentUser } from "@hazel/domain" +import { AuthMiddleware } from "@hazel/domain/rpc" +import { ConfigProvider, Effect, Layer, Redacted } from "effect" +import { Base64Url } from "effect/encoding" +import { + Etag, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/http" +import type { SuccessValue } from "effect/rpc/RpcMiddleware" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { AuthMiddlewareLive } from "../rpc/middleware/auth" +import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" +import { serviceShape } from "../test/effect-helpers" +import { AuthorizationLive } from "./auth" +import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./hazel-auth" +import { HazelSession } from "./hazel-session" +import { SessionManager } from "./session-manager" + +const api = "http://localhost:3003" +const app = "http://localhost:3000" +const key = Base64Url.encode(new Uint8Array(32).fill(7)) + +const FakeGitHub = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + const json = (body: unknown) => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }), + ) + if (url.href === "https://github.com/login/oauth/access_token") + return json({ access_token: "gho_1", token_type: "bearer", scope: "read:user,user:email" }) + if (url.href === "https://api.github.com/user") + return json({ + id: 777, + login: "octo", + name: "Octo Cat", + email: null, + avatar_url: "https://avatars.githubusercontent.com/u/777", + }) + if (url.href.startsWith("https://api.github.com/user/emails")) + return json([{ email: "octo@example.com", primary: true, verified: true }]) + return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) + }), + ), +) + +/** Clerk is not involved: any Clerk path reaching it is a test failure. */ +const NoClerk = Layer.succeed( + SessionManager, + serviceShape({ + authenticateWithBearer: () => Effect.die(new Error("Clerk should not be consulted")), + }), +) + +const successValue = { _: Symbol("success") } as SuccessValue + +const tagOf = (error: unknown) => + typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : "unknown" + +/** Stand-ins for an RPC call and an HttpApi endpoint, each behind the real middleware. */ +const ProbeRoutes = Layer.unwrap( + Effect.gen(function* () { + const rpcAuth = yield* AuthMiddleware + const apiAuth = yield* CurrentUser.Authorization + const whoAmI = Effect.map(Effect.service(CurrentUser.Context), (user) => user.email) + + return Layer.mergeAll( + HttpRouter.add( + "POST", + "/probe/rpc", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + let email = "" + yield* rpcAuth( + Effect.map(whoAmI, (value) => { + email = value + return successValue + }), + { + client: {} as never, + requestId: 1n as never, + rpc: {} as never, + payload: undefined, + headers: request.headers, + }, + ) + return yield* HttpServerResponse.json({ email }) + }).pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: tagOf(error) }, { status: 401 }), + ), + ), + ), + HttpRouter.add( + "GET", + "/probe/api", + apiAuth + .bearer( + Effect.flatMap(whoAmI, (email) => + HttpServerResponse.json({ email }).pipe(Effect.orDie), + ), + { credential: Redacted.make(""), endpoint: {} as never, group: {} as never }, + ) + .pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: tagOf(error) }, { status: 401 }), + ), + ), + ), + ) + }), +) + +describe("Hazel sign-in in the backend", () => { + let harness: ChatSyncDbHarness + let web: ReturnType + + beforeAll(async () => { + harness = await createChatSyncDbHarness() + const url = Redacted.make(harness.container.getConnectionUri()) + + const pair = await Effect.runPromise( + AccessToken.generateSigningKey("test").pipe(Effect.provide(CryptoLive)), + ) + const env = ConfigProvider.fromUnknown({ + API_BASE_URL: api, + FRONTEND_URL: app, + AUTH_GITHUB_CLIENT_ID: "Iv1.test", + AUTH_GITHUB_CLIENT_SECRET: "secret", + AUTH_BINDING_KEY: key, + AUTH_TRANSACTION_KEY: key, + AUTH_ACCESS_TOKEN_KID: "test", + AUTH_ACCESS_TOKEN_PRIVATE_JWK: JSON.stringify(Redacted.value(pair.privateKey)), + }) + + // As in app.ts: AppAuthorizationLive's pieces, under the entry point's platform. + const Auth = Layer.mergeAll(AuthMiddlewareLive, AuthorizationLive).pipe( + Layer.provideMerge(HazelSession.layer), + Layer.provideMerge(HazelAuthServicesLive), + Layer.provideMerge(HazelAuthInstance.layer), + Layer.provide(NoClerk), + ) + + web = HttpRouter.toWebHandler( + Layer.mergeAll(HazelAuthRoutes, ProbeRoutes).pipe( + Layer.provide(Auth), + Layer.provide([UserRepo.layer, BotRepo.layer]), + Layer.provide(harness.dbLayer), + Layer.provide(layerPool(url)), + Layer.provide(FakeGitHub), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layer]), + Layer.provide(ConfigProvider.layer(env)), + ) as Layer.Layer, + { disableLogger: !process.env.DEBUG_AUTH }, + ) + }, 180_000) + + afterAll(async () => { + await web?.dispose() + await harness?.stop() + }, 60_000) + + const cookies = new Map() + const cookieHeader = () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ") + const send = async (path: string, init: RequestInit & { origin?: string | null } = {}) => { + const headers = new Headers(init.headers) + if (init.origin !== null) headers.set("origin", init.origin ?? app) + headers.set("cookie", cookieHeader()) + const response = await (web.handler as (request: Request) => Promise)( + new Request(`${api}${path}`, { ...init, headers, redirect: "manual" }), + ) + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(";") + const index = pair!.indexOf("=") + const [name, value] = [pair!.slice(0, index), pair!.slice(index + 1)] + if (value === "" || /max-age=0/i.test(raw)) cookies.delete(name) + else cookies.set(name, value) + } + return response + } + const post = (path: string, payload: unknown = {}) => + send(path, { + method: "POST", + headers: { "content-type": "application/json", "x-effect-auth-csrf": "1" }, + body: JSON.stringify({ payload }), + }) + + it("signs up with GitHub and is recognised by the RPC and HttpApi middleware", async () => { + const begin = await post("/auth/signIn", { provider: "github", returnTarget: "/" }) + expect(begin.status).toBe(200) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + + const callback = await send( + `/auth/github/callback?${new URLSearchParams({ code: "c", state, iss: "https://github.com/login/oauth" })}`, + { origin: null }, + ) + expect(callback.status).toBe(303) + const registration = new URL(callback.headers.get("location")!) + expect(`${registration.origin}${registration.pathname}`).toBe(`${app}/auth/register`) + + const registered = await post("/auth/register", { + reference: registration.searchParams.get("reference"), + flowId: registration.searchParams.get("flowId"), + commandId: crypto.randomUUID(), + registration: { firstName: "Octo", lastName: "Cat" }, + }) + expect((await registered.json()).value._tag).toBe("Authenticated") + expect(cookies.has("effect-auth-session")).toBe(true) + + const rpc = await send("/probe/rpc", { method: "POST" }) + expect(await rpc.json()).toEqual({ email: "octo@example.com" }) + + const httpApi = await send("/probe/api") + expect(await httpApi.json()).toEqual({ email: "octo@example.com" }) + }, 60_000) + + it("rejects a cookie session sent from an untrusted origin", async () => { + const response = await send("/probe/rpc", { method: "POST", origin: "https://evil.example" }) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: "InvalidBearerTokenError" }) + }) + + it("accepts the session credential as a bearer token (native clients)", async () => { + const credential = cookies.get("effect-auth-session")! + const saved = new Map(cookies) + cookies.clear() + try { + const response = await send("/probe/rpc", { + method: "POST", + origin: null, + headers: { authorization: `Bearer ${credential}` }, + }) + expect(await response.json()).toEqual({ email: "octo@example.com" }) + } finally { + for (const [name, value] of saved) cookies.set(name, value) + } + }) + + it("exchanges the session for an access token that verifies against the JWKS", async () => { + const minted = await post("/auth/token") + expect(minted.status).toBe(200) + const { token } = await minted.json() + const jwks = await (await send("/.well-known/jwks.json")).json() + + const verified = await Effect.runPromise( + AccessToken.verifyAccessToken(api, token).pipe( + Effect.provide(AccessToken.Jwks.layerLocal(jwks)), + Effect.provide(CryptoLive), + ), + ) + const me = await (await send("/probe/api")).json() + expect(me.email).toBe("octo@example.com") + expect(verified.claims.iss).toBe(api) + }) + + it("signing out ends the session for the middleware too", async () => { + expect((await post("/auth/signOut")).status).toBe(200) + const response = await send("/probe/rpc", { method: "POST" }) + expect(response.status).toBe(401) + expect(cookies.has("effect-auth-session")).toBe(false) + }) +}) diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts new file mode 100644 index 000000000..b9d4ab1df --- /dev/null +++ b/apps/backend/src/services/hazel-auth.ts @@ -0,0 +1,167 @@ +import { Hooks } from "@yielded/auth" +import { AccessToken, HazelAuth, type HazelAuthConfig, makeHazelAuthLive } from "@hazel/auth/server" +import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/http" + +/** + * Hazel's own sign-in (`@hazel/auth/server`), replacing Clerk. Enabled when + * `AUTH_GITHUB_CLIENT_ID` is set; until then Clerk remains the only sign-in and these + * routes are not mounted. + * + * - `/auth/*`: sign-in, registration, passkeys and sessions (cookie on the API host). + * - `POST /auth/token`: exchanges the session for a short-lived JWT for the hosts that + * cannot see the API's cookie (Electric proxy, actors), when a signing key is set. + * - `GET /.well-known/jwks.json`: the public key those hosts verify it with. + */ + +const keyring = (name: string) => + Config.Redacted(name).pipe( + Config.map((material) => ({ activeKeyId: "v1", keys: [{ id: "v1", material }] })), + ) + +const origins = (name: string) => + Config.String(name).pipe( + Config.map((value) => + value + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin !== ""), + ), + Config.withDefault([] as Array), + ) + +const AuthConfig = Config.all({ + githubClientId: Config.option(Config.String("AUTH_GITHUB_CLIENT_ID")), + apiBaseUrl: Config.String("API_BASE_URL"), + frontendUrl: Config.String("FRONTEND_URL"), + extraOrigins: origins("AUTH_TRUSTED_ORIGINS"), + passkeyRpId: Config.option(Config.String("AUTH_PASSKEY_RP_ID")), + googleClientId: Config.option(Config.String("AUTH_GOOGLE_CLIENT_ID")), + accessTokenKid: Config.String("AUTH_ACCESS_TOKEN_KID").pipe(Config.withDefault("v1")), + accessTokenJwk: Config.option(Config.Redacted("AUTH_ACCESS_TOKEN_PRIVATE_JWK")), +}) + +const PrivateJwk = Schema.fromJsonString(Schema.Unknown) + +const make = Effect.gen(function* () { + const env = yield* AuthConfig + if (Option.isNone(env.githubClientId)) { + yield* Effect.logInfo("[auth] AUTH_GITHUB_CLIENT_ID is not set; Hazel sign-in is disabled") + return Option.none() + } + + const origin = new URL(env.apiBaseUrl).origin + const appOrigin = new URL(env.frontendUrl).origin + const trustedOrigins = [...new Set([appOrigin, ...env.extraOrigins])] + + const google = Option.isSome(env.googleClientId) + ? { + clientId: env.googleClientId.value, + clientSecret: yield* Config.Redacted("AUTH_GOOGLE_CLIENT_SECRET"), + } + : undefined + + const config: HazelAuthConfig = { + origin, + appOrigin, + trustedOrigins, + github: { + clientId: env.githubClientId.value, + clientSecret: yield* Config.Redacted("AUTH_GITHUB_CLIENT_SECRET"), + }, + ...(google === undefined ? {} : { google }), + passkey: { + rpId: Option.getOrElse(env.passkeyRpId, () => new URL(appOrigin).hostname), + origins: trustedOrigins, + }, + keys: { + binding: yield* keyring("AUTH_BINDING_KEY"), + transaction: yield* keyring("AUTH_TRANSACTION_KEY"), + }, + returnTargets: ["/"], + registrationPath: "/auth/register", + } + + const accessTokens = Option.map( + env.accessTokenJwk, + (jwk): AccessToken.SigningKey => ({ + kid: env.accessTokenKid, + privateJwk: Redacted.make(Schema.decodeSync(PrivateJwk)(Redacted.value(jwk))), + }), + ) + + return Option.some({ ...makeHazelAuthLive(config), config, accessTokens }) +}) + +/** + * The configured auth instance, shared by the routes (`HazelAuthRoutes`) and the services + * every request handler can use (`HazelAuthServicesLive`) so both come from one `Http.make`. + */ +export class HazelAuthInstance extends Context.Service()( + "@hazel/backend/HazelAuthInstance", + { + make, + }, +) { + static readonly layer = Layer.effect(this, this.make) +} + +/** `HazelAuth` and its storage, for the RPC and HttpApi auth middleware. Empty when disabled. */ +export const HazelAuthServicesLive = Layer.unwrap( + Effect.map(Effect.service(HazelAuthInstance), (instance) => + Option.match(instance, { + onNone: () => Layer.empty, + onSome: ({ http, Services }) => + http.layer.pipe(Layer.provideMerge(Services), Layer.provide(Hooks.LifecycleHooks.empty)), + }), + ), +) + +/** Session → access token exchange, and the key set that verifies those tokens. */ +const accessTokenRoutes = (issuerOrigin: string, key: AccessToken.SigningKey) => + Layer.unwrap( + Effect.gen(function* () { + const issuer = yield* AccessToken.makeIssuer(issuerOrigin, key) + const publicJwk = yield* AccessToken.publicJwk(key) + return Layer.mergeAll( + HttpRouter.add( + "POST", + "/auth/token", + Effect.gen(function* () { + const auth = yield* HazelAuth + const session = yield* auth.getSession() + if (session === null) return HttpServerResponse.empty({ status: 401 }) + const token = yield* issuer.mint(session) + return yield* HttpServerResponse.json({ + token: Redacted.value(token), + expiresIn: AccessToken.lifetimeSeconds, + }) + }), + ), + HttpRouter.add( + "GET", + "/.well-known/jwks.json", + HttpServerResponse.json({ keys: [publicJwk] }).pipe( + Effect.map(HttpServerResponse.setHeader("cache-control", "public, max-age=300")), + ), + ), + ) + }), + ) + +/** `/auth/*` routes. Needs `HazelAuthServicesLive` below it. Empty when disabled. */ +export const HazelAuthRoutes = Layer.unwrap( + Effect.map(Effect.service(HazelAuthInstance), (instance) => + Option.match(instance, { + onNone: () => Layer.empty, + onSome: ({ http, config, accessTokens }) => + Layer.mergeAll( + http.routes(), + Option.match(accessTokens, { + onNone: () => Layer.empty, + onSome: (key) => accessTokenRoutes(config.origin, key).pipe(http.middleware), + }), + ), + }), + ), +) diff --git a/apps/backend/src/services/hazel-session.ts b/apps/backend/src/services/hazel-session.ts new file mode 100644 index 000000000..deb3b5677 --- /dev/null +++ b/apps/backend/src/services/hazel-session.ts @@ -0,0 +1,116 @@ +import { HazelAuth } from "@hazel/auth/server" +import { UserRepo } from "@hazel/backend-core" +import { CurrentUser, InvalidBearerTokenError, SessionLoadError } from "@hazel/domain" +import type { UserId } from "@hazel/schema" +import { Context, Effect, Layer, Option, Redacted } from "effect" +import { Cookies, Headers } from "effect/http" +import { HazelAuthInstance } from "./hazel-auth" + +/** + * Resolves Hazel sessions (`@hazel/auth/server`) to the current user for the RPC and + * HttpApi auth middleware. Browsers send the session as an HttpOnly cookie on the API + * host; native clients send the credential as a bearer token. + */ +export class HazelSession extends Context.Service()("@hazel/backend/HazelSession", { + make: Effect.gen(function* () { + const instance = yield* HazelAuthInstance + const auth = yield* Effect.serviceOption(HazelAuth) + const userRepo = yield* UserRepo + + const enabled = Option.isSome(instance) && Option.isSome(auth) + const cookieName = Option.isSome(instance) ? instance.value.sessionCookieName : undefined + const allowedOrigins = new Set( + Option.isSome(instance) + ? [instance.value.config.origin, ...(instance.value.config.trustedOrigins ?? [])] + : [], + ) + + const invalid = (detail: string) => + new InvalidBearerTokenError({ message: "Invalid session", detail }) + + const loadUser = (subjectId: string) => + userRepo.findById(subjectId as UserId).pipe( + Effect.catchTags({ + DatabaseError: (error) => + Effect.fail( + new SessionLoadError({ message: "Failed to load user", detail: String(error) }), + ), + }), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(invalid("User not found")), + onSome: (user) => + user.deletedAt !== null + ? Effect.fail(invalid("User is deleted")) + : Effect.succeed( + new CurrentUser.Schema({ + id: user.id, + role: "member", + organizationId: undefined, + avatarUrl: user.avatarUrl ?? undefined, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + isOnboarded: user.isOnboarded, + timezone: user.timezone, + settings: user.settings, + }), + ), + }), + ), + ) + + /** Verifies a session credential; `None` when it is not a valid Hazel session. */ + const verify = ( + credential: string, + ): Effect.Effect, InvalidBearerTokenError | SessionLoadError> => + Effect.gen(function* () { + if (Option.isNone(auth)) return Option.none() + const session = yield* auth.value.verifySession(Redacted.make(credential)).pipe( + Effect.map(Option.some), + Effect.catchTag("SessionInvalid", () => Effect.succeedNone), + // Every other outcome is the session store failing, not a bad credential. + Effect.mapError( + (error) => + new SessionLoadError({ + message: "Session verification failed", + detail: error._tag, + }), + ), + ) + if (Option.isNone(session)) return Option.none() + return Option.some(yield* loadUser(session.value.subjectId)) + }) + + /** + * The session cookie, if the request carries one. Cookie-authenticated requests must + * come from a trusted origin: mutations are POSTs, which browsers always send with an + * Origin, so a missing or foreign Origin means a cross-site request. + */ + const fromCookie = ( + headers: Headers.Headers, + method: string, + ): Effect.Effect, InvalidBearerTokenError | SessionLoadError> => + Effect.gen(function* () { + if (!enabled || cookieName === undefined) return Option.none() + const cookie = Option.flatMap(Headers.get(headers, "cookie"), (header) => + Option.fromNullishOr(Cookies.parseHeader(header)[cookieName]), + ) + if (Option.isNone(cookie)) return Option.none() + + const origin = Headers.get(headers, "origin") + const safe = method === "GET" || method === "HEAD" + if (!safe && (Option.isNone(origin) || !allowedOrigins.has(origin.value))) { + return yield* Effect.fail(invalid("Untrusted origin for a cookie session")) + } + + const user = yield* verify(cookie.value) + if (Option.isNone(user)) return yield* Effect.fail(invalid("Session expired or revoked")) + return user + }) + + return { enabled, verify, fromCookie } as const + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(UserRepo.layer)) +} diff --git a/apps/backend/src/worker/env.ts b/apps/backend/src/worker/env.ts index 6141e9c9d..4817b76ad 100644 --- a/apps/backend/src/worker/env.ts +++ b/apps/backend/src/worker/env.ts @@ -28,6 +28,19 @@ export const apiEnv = ({ stage, urls }: HazelStackContext) => requirePlainEntry("CLERK_PUBLISHABLE_KEY"), optionalSecret("CLERK_WEBHOOK_SECRET"), + // Auth (Hazel, replacing Clerk): off until AUTH_GITHUB_CLIENT_ID is set. Keys come from + // `bun run --cwd packages/auth generate-keys`. + optionalPlain("AUTH_GITHUB_CLIENT_ID"), + optionalSecret("AUTH_GITHUB_CLIENT_SECRET"), + optionalPlain("AUTH_GOOGLE_CLIENT_ID"), + optionalSecret("AUTH_GOOGLE_CLIENT_SECRET"), + optionalSecret("AUTH_BINDING_KEY"), + optionalSecret("AUTH_TRANSACTION_KEY"), + optionalPlain("AUTH_ACCESS_TOKEN_KID"), + optionalSecret("AUTH_ACCESS_TOKEN_PRIVATE_JWK"), + optionalPlain("AUTH_PASSKEY_RP_ID"), + optionalPlain("AUTH_TRUSTED_ORIGINS"), + // Uploads: presigned against the bucket's S3 API (R2) requirePlainEntry("S3_BUCKET"), requirePlainEntry("S3_ENDPOINT"), diff --git a/apps/backend/src/worker/platform.ts b/apps/backend/src/worker/platform.ts index a44347239..be8df5375 100644 --- a/apps/backend/src/worker/platform.ts +++ b/apps/backend/src/worker/platform.ts @@ -3,6 +3,7 @@ * caches, `ConfigProvider.fromEnv`), rebuilt over Cloudflare bindings. */ import type { KVNamespace } from "@cloudflare/workers-types" +import { AuthSqlConnection, layerDirect as authSqlDirect, makeRequestConnection } from "@hazel/auth/server" import { Database } from "@hazel/db" import { layerKvResultPersistence } from "@hazel/effect-cloudflare" import { readHazelDbBinding } from "@hazel/infra/cloudflare" @@ -39,6 +40,8 @@ export const requestPlatformLive = (env: Record, botGateways: B BotGatewayTransport.layerDurableObject(botGateways), // Suspended: init also runs at plan time, where the KV binding does not exist yet. Layer.suspend(() => layerKvResultPersistence(cacheNamespace(env))), + // Auth storage's startup schema checks run outside any request's connection. + Layer.suspend(() => authSqlDirect(Redacted.make(hazelDbConnectionString(env)))), workerEnvLayer(env), ) @@ -58,12 +61,25 @@ export const objectPlatformLive = (env: Record, botGateways: Bo workerEnvLayer(env), ) -/** Opens the request's Postgres client; it connects lazily and is closed with the request scope. */ -export const provideRequestDatabase = (env: Record) => - Effect.provideServiceEffect( - Database.DatabaseConnection, - Effect.acquireRelease( - Effect.sync(() => Database.makeRequestConnection(hazelDbConnectionString(env))), - (connection) => Effect.promise(() => connection.end()).pipe(Effect.ignore), - ), - ) +/** + * Opens the request's Postgres clients (Drizzle, and Effect SQL for auth storage); both + * connect lazily and are closed with the request scope. + */ +export const provideRequestDatabase = + (env: Record) => + (effect: Effect.Effect) => + effect.pipe( + Effect.provideServiceEffect( + AuthSqlConnection, + Effect.flatMap(Effect.scope, (scope) => + makeRequestConnection(Redacted.make(hazelDbConnectionString(env)), scope), + ), + ), + Effect.provideServiceEffect( + Database.DatabaseConnection, + Effect.acquireRelease( + Effect.sync(() => Database.makeRequestConnection(hazelDbConnectionString(env))), + (connection) => Effect.promise(() => connection.end()).pipe(Effect.ignore), + ), + ), + ) diff --git a/bun.lock b/bun.lock index b8dfd696e..9211ff6ed 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "@yielded/auth": "0.1.0-beta.32", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", diff --git a/packages/auth/package.json b/packages/auth/package.json index 7ea2d5761..a832a1b93 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -12,6 +12,10 @@ "./contract": "./src/yielded/contract.ts", "./server": "./src/yielded/index.ts" }, + "scripts": { + "test:integration": "vitest run src/yielded", + "generate-keys": "bun scripts/generate-keys.ts" + }, "dependencies": { "@clerk/backend": "^3.3.0", "@effect/sql-pg": "catalog:effect", @@ -33,8 +37,5 @@ "@effect/platform-node": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" - }, - "scripts": { - "test:integration": "vitest run src/yielded" } } diff --git a/packages/auth/scripts/generate-keys.ts b/packages/auth/scripts/generate-keys.ts new file mode 100644 index 000000000..3e5c41c27 --- /dev/null +++ b/packages/auth/scripts/generate-keys.ts @@ -0,0 +1,22 @@ +/** + * Prints fresh secrets for Hazel sign-in, as env lines: + * - AUTH_BINDING_KEY / AUTH_TRANSACTION_KEY: 32 random bytes each (request binding and + * OAuth state encryption). + * - AUTH_ACCESS_TOKEN_PRIVATE_JWK: an ES256 key for the access tokens the Electric proxy + * and actors verify. Changing it invalidates outstanding access tokens (5 minutes). + * + * Usage: bun run --cwd packages/auth generate-keys + */ +import { Effect, Redacted } from "effect" +import { Base64Url } from "effect/encoding" +import { AccessToken, CryptoLive } from "../src/yielded/index.ts" + +const randomKey = () => Base64Url.encode(crypto.getRandomValues(new Uint8Array(32))) +const kid = new Date().toISOString().slice(0, 10) + +const pair = await Effect.runPromise(AccessToken.generateSigningKey(kid).pipe(Effect.provide(CryptoLive))) + +console.log(`AUTH_BINDING_KEY=${randomKey()}`) +console.log(`AUTH_TRANSACTION_KEY=${randomKey()}`) +console.log(`AUTH_ACCESS_TOKEN_KID=${kid}`) +console.log(`AUTH_ACCESS_TOKEN_PRIVATE_JWK='${JSON.stringify(Redacted.value(pair.privateKey))}'`) diff --git a/packages/auth/src/yielded/access-token.ts b/packages/auth/src/yielded/access-token.ts index e284f5003..a8d84bfdc 100644 --- a/packages/auth/src/yielded/access-token.ts +++ b/packages/auth/src/yielded/access-token.ts @@ -1,6 +1,8 @@ +import { InvalidKey } from "@yielded/jose/Errors" import * as Jwk from "@yielded/jose/Jwk" import * as Jwks from "@yielded/jose/Jwks" import * as Jwt from "@yielded/jose/Jwt" +import { Signature } from "@yielded/crypto/Signature" import { DateTime, Effect, Redacted, Schema } from "effect" /** @@ -8,9 +10,10 @@ import { DateTime, Effect, Redacted, Schema } from "effect" * The browser's session cookie is host-only on api.hazel.sh, so those services can't see * it. The backend exchanges a valid session for this ES256 JWT; the services verify it * locally against the published JWKS, without a database or network call. + * + * The issuer is the API origin, so each stage's tokens only verify on that stage. */ -export const issuer = "https://api.hazel.sh" export const audience = "hazel-services" export const lifetimeSeconds = 300 @@ -23,12 +26,17 @@ export const AccessClaims = Schema.Struct({ exp: Schema.Finite, }) -export const makeIssuer = (input: { +export interface SigningKey { readonly kid: string + /** ES256 private JWK. */ readonly privateJwk: Redacted.Redacted -}) => +} + +export const makeIssuer = (issuer: string, key: SigningKey) => Effect.gen(function* () { - const key = yield* Jwk.importPrivate(input.privateJwk, "ES256") + const privateKey = yield* Jwk.importPrivate(key.privateJwk, "ES256") + // Held here so minting needs nothing from the request. + const signature = yield* Signature return { mint: (session: { readonly subjectId: string; readonly sessionId: string }) => @@ -44,15 +52,28 @@ export const makeIssuer = (input: { iat: now, exp: now + lifetimeSeconds, }, - key, - { alg: "ES256", kid: input.kid, typ: "at+jwt" }, + privateKey, + { alg: "ES256", kid: key.kid, typ: "at+jwt" }, ) - }), + }).pipe(Effect.provideService(Signature, signature)), } }) +/** The public half of a signing key, as published in the JWKS. */ +export const publicJwk = (key: SigningKey) => + Effect.gen(function* () { + const jwk = Redacted.value(key.privateJwk) + if (typeof jwk !== "object" || jwk === null) return yield* InvalidKey.make({}) + const { d: _secret, ...publicPart } = jwk as Record + const imported = yield* Jwk.importPublic( + { ...publicPart, kid: key.kid, alg: "ES256", use: "sig" }, + "ES256", + ) + return yield* Jwk.exportPublic(imported) + }) + /** Verification used by electric-proxy / actors. Requires `Jwks` (local or remote). */ -export const verifyAccessToken = (token: string) => +export const verifyAccessToken = (issuer: string, token: string) => Jwt.verifyWithKeySet(AccessClaims, Redacted.make(token), { algorithms: ["ES256"], issuer, @@ -61,6 +82,7 @@ export const verifyAccessToken = (token: string) => requiredClaims: ["iss", "sub", "aud", "exp", "iat"], }) +/** A new ES256 key pair; the private JWK goes in `AUTH_ACCESS_TOKEN_PRIVATE_JWK`. */ export const generateSigningKey = (kid: string) => Jwk.generateKeyPair({ algorithm: "ES256", kid }) export { Jwks } diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts index fa4a8e968..0ced0356c 100644 --- a/packages/auth/src/yielded/hazel-auth.test.ts +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -80,6 +80,7 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { const { http, Services, sessionCookieName } = makeHazelAuthLive({ origin, + appOrigin: webOrigin, trustedOrigins: [webOrigin], github: { clientId: "Iv1.test", clientSecret: Redacted.make("test-secret") }, passkey: { rpId: "localhost", origins: [webOrigin] }, @@ -244,7 +245,9 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { ) cookies.store(callback) expect(callback.status).toBe(303) - return new URL(callback.headers.get("location")!, origin) + const location = new URL(callback.headers.get("location")!) + expect(location.origin).toBe(webOrigin) + return location } const register = (cookies: Jar, location: URL) => @@ -353,22 +356,34 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { const pair = await Effect.runPromise( AccessToken.generateSigningKey("k1").pipe(Effect.provide(CryptoLive)), ) + const key = { kid: "k1", privateJwk: pair.privateKey } const issuer = await Effect.runPromise( - AccessToken.makeIssuer({ kid: "k1", privateJwk: pair.privateKey }).pipe( - Effect.provide(CryptoLive), - ), + AccessToken.makeIssuer(origin, key).pipe(Effect.provide(CryptoLive)), ) + // The JWKS publishes the key derived from the private JWK alone. + const published = await Effect.runPromise(AccessToken.publicJwk(key).pipe(Effect.provide(CryptoLive))) + expect(published).not.toHaveProperty("d") const token = await Effect.runPromise( issuer .mint({ subjectId: "00000000-0000-4000-8000-000000000001", sessionId: "s1" }) .pipe(Effect.provide(CryptoLive)), ) const verified = await Effect.runPromise( - AccessToken.verifyAccessToken(Redacted.value(token)).pipe( - Effect.provide(AccessToken.Jwks.layerLocal({ keys: [pair.publicKey] })), + AccessToken.verifyAccessToken(origin, Redacted.value(token)).pipe( + Effect.provide(AccessToken.Jwks.layerLocal({ keys: [published] })), Effect.provide(CryptoLive), ), ) expect(verified.claims.sub).toBe("00000000-0000-4000-8000-000000000001") + + // Another stage's issuer does not verify. + const foreign = await Effect.runPromise( + AccessToken.verifyAccessToken("https://api.hazel.sh", Redacted.value(token)).pipe( + Effect.provide(AccessToken.Jwks.layerLocal({ keys: [published] })), + Effect.provide(CryptoLive), + Effect.result, + ), + ) + expect(foreign._tag).toBe("Failure") }) }) diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts index fc57c4c78..40953089f 100644 --- a/packages/auth/src/yielded/server.ts +++ b/packages/auth/src/yielded/server.ts @@ -33,7 +33,9 @@ export interface Keyring { export interface HazelAuthConfig { /** The backend origin that serves /auth/* (api.hazel.sh; localhost:3003 in dev). */ readonly origin: string - /** The web app origin(s) calling the API with credentials (app.hazel.sh). */ + /** The web app (app.hazel.sh); sign-in redirects land here. */ + readonly appOrigin: string + /** Origins allowed to call the auth routes with credentials, besides `origin`. */ readonly trustedOrigins?: ReadonlyArray readonly github: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } readonly google?: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } @@ -42,9 +44,9 @@ export interface HazelAuthConfig { readonly binding: Keyring readonly transaction: Keyring } - /** Paths the OAuth callback may return to after sign-in. */ + /** Paths on the web app the OAuth callback may return to after sign-in. */ readonly returnTargets: ReadonlyArray - /** Where the callback sends a first-time user to pick their name. */ + /** Path on the web app where the callback sends a first-time user to pick their name. */ readonly registrationPath: string } @@ -81,12 +83,16 @@ export const makeHazelAuthHttp = (config: HazelAuthConfig) => { HazelAuthApi.actions.completeSignIn.route.operation.rpc.successSchema, )(value).pipe(Effect.orDie) + // Targets are paths on the web app, which is a different origin from the API. const target = "_tag" in result && result._tag === "RegistrationRequired" ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` : result.returnTarget - return new Response(null, { status: 303, headers: { location: target } }) + return new Response(null, { + status: 303, + headers: { location: new URL(target, config.appOrigin).href }, + }) }), }, }) diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts index c8cda8333..0738ef006 100644 --- a/packages/auth/src/yielded/sql.ts +++ b/packages/auth/src/yielded/sql.ts @@ -62,12 +62,14 @@ export const layerRouted: Layer.Layer = Layer.effect( ).pipe(Layer.provide(Reactivity.layer)) /** One pooled client for a long-running process (Bun). */ -export const layerPool = (url: Redacted.Redacted) => +export const layerPool = (url: Redacted.Redacted, options: { readonly ssl?: boolean } = {}) => Layer.effect( AuthSqlConnection, - PgClient.make({ url, maxConnections: 5 }).pipe( - Effect.map((client) => ({ client: Effect.succeed(client) })), - ), + PgClient.make({ + url, + maxConnections: 5, + ...(options.ssl === undefined ? {} : { ssl: options.ssl }), + }).pipe(Effect.map((client) => ({ client: Effect.succeed(client) }))), ).pipe(Layer.provide(Reactivity.layer)) const connect = (url: Redacted.Redacted) => From 796c6bba17df8d27069589b9054ca0298ff69494 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 11 Oct 2026 01:11:16 +0200 Subject: [PATCH 03/10] feat(auth): carry Clerk users over to Hazel sign-in - import-clerk-identities links each user's GitHub and Google accounts from Clerk to their existing Hazel user (dry run by default, idempotent), so their first sign-in lands in their own account. - A first-time GitHub or Google sign-in whose provider-verified email belongs to exactly one existing user links to that user and restarts sign-in via the web app's /auth/continue, instead of registering a duplicate account. Co-Authored-By: Claude Opus 5.5 --- .../import-clerk-identities.ts | 153 ++++++++++++++++++ apps/backend/src/services/hazel-auth.test.ts | 53 +++++- apps/backend/src/services/hazel-auth.ts | 1 + packages/auth/src/yielded/hazel-auth.test.ts | 89 ++++++++++ packages/auth/src/yielded/identities.ts | 115 +++++++++++++ packages/auth/src/yielded/index.ts | 10 ++ packages/auth/src/yielded/server.ts | 61 ++++++- packages/auth/src/yielded/sql.ts | 4 + packages/auth/src/yielded/storage.ts | 47 +++++- 9 files changed, 522 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts create mode 100644 packages/auth/src/yielded/identities.ts diff --git a/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts new file mode 100644 index 000000000..b5b54ddc2 --- /dev/null +++ b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts @@ -0,0 +1,153 @@ +/** + * Carries users over from Clerk to Hazel sign-in: links each user's GitHub and Google + * accounts (as recorded by Clerk) to their existing Hazel user, so their first sign-in on + * the new system lands in their own account. + * + * Dry run by default: reads Clerk and the database, writes nothing, and prints how users + * split up. `--apply` writes the links. Safe to re-run (linking is idempotent), so it can + * run nightly until the cutover to pick up new Clerk sign-ups. + * + * Usage: + * CLERK_SECRET_KEY=… DATABASE_URL=… bun run src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts + * CLERK_SECRET_KEY=… DATABASE_URL=… bun run src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts --apply + * + * Output: a summary, and ./exports/hazel-auth-import-report.json with the users in each + * group (it contains emails: keep it local). + * + * Groups: + * - linked: has GitHub or Google in Clerk. Imported; signs straight in. + * - emailOnly: no GitHub or Google. Signs in with GitHub or Google whose verified email + * matches theirs (linked automatically at sign-in), unless the email is shared. + * - emailShared: email-only and another active user has the same email, so sign-in can't + * match it automatically. Needs a manual link. + * - conflicts: the provider account is already linked to a different Hazel user. + * - missingInHazel / missingInClerk: present on one side only. + */ +import { mkdirSync, writeFileSync } from "node:fs" +import { createClerkClient, type User as ClerkUser } from "@clerk/backend" +import { PgClient } from "@effect/sql-pg" +import { CryptoLive, isProvider, linkIdentity, type Provider } from "@hazel/auth/server" +import { Config, Effect, Redacted } from "effect" +import { SqlClient } from "effect/sql" + +const apply = process.argv.includes("--apply") +const REPORT_PATH = "./exports/hazel-auth-import-report.json" +const PAGE_SIZE = 500 + +type HazelUser = { id: string; externalId: string; email: string; deleted: boolean; userType: string } +type Entry = { hazelUserId?: string; clerkUserId?: string; email?: string; detail?: string } + +const listClerkUsers = (secretKey: string) => + Effect.tryPromise(async () => { + const clerk = createClerkClient({ secretKey }) + const users: Array = [] + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = await clerk.users.getUserList({ limit: PAGE_SIZE, offset, orderBy: "+created_at" }) + users.push(...page.data) + if (page.data.length < PAGE_SIZE) return users + } + }) + +/** Verified GitHub and Google accounts; Clerk names providers `oauth_github` / `oauth_google`. */ +const providerAccounts = (user: ClerkUser) => + user.externalAccounts.flatMap((account) => { + const provider = account.provider.replace(/^oauth_/, "") + if (!isProvider(provider) || account.providerUserId === "") return [] + if (account.verification !== null && account.verification.status !== "verified") return [] + return [{ provider: provider as Provider, subject: account.providerUserId }] + }) + +const primaryEmail = (user: ClerkUser) => + user.emailAddresses.find((address) => address.id === user.primaryEmailAddressId)?.emailAddress + +const program = Effect.gen(function* () { + const secretKey = yield* Config.Redacted("CLERK_SECRET_KEY") + const sql = yield* SqlClient.SqlClient + + const hazelUsers = yield* sql` + select id::text as id, "externalId", email, "deletedAt" is not null as deleted, "userType" + from users` + const byClerkId = new Map(hazelUsers.map((user) => [user.externalId, user])) + const activeByEmail = new Map() + for (const user of hazelUsers) { + if (user.deleted || user.userType !== "user") continue + const email = user.email.toLowerCase() + activeByEmail.set(email, (activeByEmail.get(email) ?? 0) + 1) + } + + const clerkUsers = yield* listClerkUsers(Redacted.value(secretKey)) + yield* Effect.log(`Clerk users: ${clerkUsers.length}. Hazel users: ${hazelUsers.length}.`) + + const report = { + linked: [] as Array, + emailOnly: [] as Array, + emailShared: [] as Array, + conflicts: [] as Array, + missingInHazel: [] as Array, + missingInClerk: [] as Array, + } + let identitiesLinked = 0 + let identitiesAlreadyLinked = 0 + + for (const clerkUser of clerkUsers) { + const email = primaryEmail(clerkUser) + const hazel = byClerkId.get(clerkUser.id) + if (hazel === undefined) { + report.missingInHazel.push({ clerkUserId: clerkUser.id, ...(email ? { email } : {}) }) + continue + } + if (hazel.deleted || hazel.userType !== "user") continue + const entry: Entry = { hazelUserId: hazel.id, clerkUserId: clerkUser.id, email: hazel.email } + + const accounts = providerAccounts(clerkUser) + if (accounts.length === 0) { + const shared = (activeByEmail.get(hazel.email.toLowerCase()) ?? 0) > 1 + ;(shared ? report.emailShared : report.emailOnly).push(entry) + continue + } + + report.linked.push({ ...entry, detail: accounts.map((account) => account.provider).join("+") }) + if (!apply) continue + for (const account of accounts) { + const result = yield* linkIdentity({ ...account, userId: hazel.id }) + if (result._tag === "Linked") identitiesLinked++ + else if (result._tag === "AlreadyLinked") identitiesAlreadyLinked++ + else + report.conflicts.push({ + ...entry, + detail: `${account.provider} account already belongs to Hazel user ${result.ownerId}`, + }) + } + } + + const clerkIds = new Set(clerkUsers.map((user) => user.id)) + for (const user of hazelUsers) { + if (user.deleted || user.userType !== "user" || clerkIds.has(user.externalId)) continue + // Users created by Hazel sign-in itself are not in Clerk by design. + if (user.externalId.startsWith("hazel:")) continue + report.missingInClerk.push({ hazelUserId: user.id, email: user.email, detail: user.externalId }) + } + + mkdirSync("./exports", { recursive: true }) + writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t")) + + yield* Effect.log(apply ? "Applied." : "Dry run: nothing written. Re-run with --apply to link.") + yield* Effect.log(` linked (GitHub/Google): ${report.linked.length}`) + yield* Effect.log(` email only: ${report.emailOnly.length}`) + yield* Effect.log(` email shared (manual): ${report.emailShared.length}`) + yield* Effect.log(` missing in Hazel: ${report.missingInHazel.length}`) + yield* Effect.log(` missing in Clerk: ${report.missingInClerk.length}`) + if (apply) { + yield* Effect.log(` identities linked now: ${identitiesLinked}`) + yield* Effect.log(` already linked: ${identitiesAlreadyLinked}`) + yield* Effect.log(` conflicts (manual): ${report.conflicts.length}`) + } + yield* Effect.log(`Report: ${REPORT_PATH}`) +}) + +const SqlLive = PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") }) + +Effect.runPromise(program.pipe(Effect.provide(SqlLive), Effect.provide(CryptoLive))).catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/backend/src/services/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts index 5857d739d..06b4eb468 100644 --- a/apps/backend/src/services/hazel-auth.test.ts +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -8,6 +8,7 @@ import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" import * as NodeServices from "@effect/platform-node/NodeServices" import { AccessToken, CryptoLive, layerPool } from "@hazel/auth/server" import { BotRepo, UserRepo } from "@hazel/backend-core" +import { Database, schema } from "@hazel/db" import { CurrentUser } from "@hazel/domain" import { AuthMiddleware } from "@hazel/domain/rpc" import { ConfigProvider, Effect, Layer, Redacted } from "effect" @@ -34,6 +35,9 @@ const api = "http://localhost:3003" const app = "http://localhost:3000" const key = Base64Url.encode(new Uint8Array(32).fill(7)) +/** The GitHub account the fake signs in as. */ +let github = { id: 777, email: "octo@example.com" } + const FakeGitHub = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request, url) => @@ -47,14 +51,14 @@ const FakeGitHub = Layer.succeed( return json({ access_token: "gho_1", token_type: "bearer", scope: "read:user,user:email" }) if (url.href === "https://api.github.com/user") return json({ - id: 777, - login: "octo", + id: github.id, + login: `octo${github.id}`, name: "Octo Cat", email: null, - avatar_url: "https://avatars.githubusercontent.com/u/777", + avatar_url: `https://avatars.githubusercontent.com/u/${github.id}`, }) if (url.href.startsWith("https://api.github.com/user/emails")) - return json([{ email: "octo@example.com", primary: true, verified: true }]) + return json([{ email: github.email, primary: true, verified: true }]) return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) }), ), @@ -275,4 +279,45 @@ describe("Hazel sign-in in the backend", () => { expect(response.status).toBe(401) expect(cookies.has("effect-auth-session")).toBe(false) }) + + it("a Clerk user's first GitHub sign-in lands in their existing account by verified email", async () => { + // Signed up through Clerk with email: a users row and no linked accounts. + const [existing] = await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.execute((client) => + client + .insert(schema.usersTable) + .values({ + externalId: "user_clerk_email_only", + email: "Clerk.User@Example.com", + firstName: "Clerk", + lastName: "User", + isOnboarded: true, + }) + .returning({ id: schema.usersTable.id }), + ) + }), + ) + github = { id: 888, email: "clerk.user@example.com" } + + const signIn = async () => { + const begin = await post("/auth/signIn", { provider: "github", returnTarget: "/" }) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + const callback = await send( + `/auth/github/callback?${new URLSearchParams({ code: "c", state, iss: "https://github.com/login/oauth" })}`, + { origin: null }, + ) + return new URL(callback.headers.get("location")!) + } + + const first = await signIn() + expect(`${first.origin}${first.pathname}`).toBe(`${app}/auth/continue`) + expect(first.searchParams.get("provider")).toBe("github") + + expect((await signIn()).href).toBe(`${app}/`) + const rpc = await send("/probe/rpc", { method: "POST" }) + expect(await rpc.json()).toEqual({ email: "Clerk.User@Example.com" }) + expect(existing).toBeDefined() + }, 60_000) }) diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts index b9d4ab1df..bfdaced04 100644 --- a/apps/backend/src/services/hazel-auth.ts +++ b/apps/backend/src/services/hazel-auth.ts @@ -80,6 +80,7 @@ const make = Effect.gen(function* () { }, returnTargets: ["/"], registrationPath: "/auth/register", + continuePath: "/auth/continue", } const accessTokens = Option.map( diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts index 0ced0356c..8f5c514e9 100644 --- a/packages/auth/src/yielded/hazel-auth.test.ts +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -29,6 +29,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest" import * as AccessToken from "./access-token.ts" import { HazelAuth } from "./auth.ts" import { CryptoLive, makeHazelAuthLive } from "./server.ts" +import { identityKey, linkIdentity } from "./identities.ts" import { AuthSqlConnection, AuthSqlDirect, layerDirect, makeRequestConnection } from "./sql.ts" const DATABASE_URL = process.env.AUTH_TEST_DATABASE_URL @@ -87,6 +88,7 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { keys: { binding: keyring(1), transaction: keyring(2) }, returnTargets: ["/"], registrationPath: "/auth/register-profile", + continuePath: "/auth/continue", }) const MeRoute = HttpRouter.add( @@ -386,4 +388,91 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { ) expect(foreign._tag).toBe("Failure") }) + + // ---- Carrying users over from Clerk ---- + + /** A user who signed up through Clerk: a users row with a Clerk id and nothing else. */ + const insertClerkUser = (email: string) => + query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const [row] = yield* sql<{ id: string }>` + insert into users ("externalId", email, "firstName", "lastName", "isOnboarded") + values (${`user_${crypto.randomUUID()}`}, ${email}, 'Clerk', 'User', true) + returning id::text as id` + return row!.id + }), + ) + + const usersWithEmail = (email: string) => + query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return (yield* sql`select id from users where lower(email) = lower(${email})`).length + }), + ) + + test("the identity key matches the one the library writes", async () => { + const [row] = await query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return yield* sql<{ identityKey: string }>` + select "identityKey" from auth_oauth_identities where "externalSubject" = '4242'` + }), + ) + const computed = await Effect.runPromise( + identityKey({ provider: "github", subject: "4242" }).pipe(Effect.provide(CryptoLive)), + ) + expect(computed).toBe(row!.identityKey) + }) + + test("an imported GitHub account signs straight into the existing account", async () => { + const userId = await insertClerkUser("imported@example.com") + const link = (subject: string) => + query(linkIdentity({ provider: "github", subject, userId }).pipe(Effect.provide(CryptoLive))) + expect(await link("9001")).toEqual({ _tag: "Linked" }) + expect(await link("9001")).toEqual({ _tag: "AlreadyLinked" }) + + githubUserId = 9001 + const browser = jar() + expect((await signInWithGitHub(browser)).pathname).toBe("/") + const me = await (await call(browser, "GET", "/me")).json() + expect(me.userId).toBe(userId) + expect(me.displayName).toBe("Clerk User") + }, 60_000) + + test("an account linked to someone else is reported, not reassigned", async () => { + const other = await insertClerkUser("other@example.com") + const result = await query( + linkIdentity({ provider: "github", subject: "9001", userId: other }).pipe( + Effect.provide(CryptoLive), + ), + ) + expect(result._tag).toBe("Conflict") + }) + + test("a verified email match continues into the existing account, with no duplicate", async () => { + // The fake GitHub reports octo@example.com as the verified primary email. + const userId = await insertClerkUser("Octo5555@Example.com") + githubUserId = 5555 + const browser = jar() + + const first = await signInWithGitHub(browser) + expect(first.pathname).toBe("/auth/continue") + expect(first.searchParams.get("provider")).toBe("github") + + // The web app's continue page starts sign-in again; it now resolves to the user. + expect((await signInWithGitHub(browser)).pathname).toBe("/") + const me = await (await call(browser, "GET", "/me")).json() + expect(me.userId).toBe(userId) + expect(await usersWithEmail("octo5555@example.com")).toBe(1) + }, 60_000) + + test("an ambiguous email never links", async () => { + await insertClerkUser("octo6666@example.com") + await insertClerkUser("octo6666@example.com") + githubUserId = 6666 + const browser = jar() + expect((await signInWithGitHub(browser)).pathname).toBe("/auth/register-profile") + }, 60_000) }) diff --git a/packages/auth/src/yielded/identities.ts b/packages/auth/src/yielded/identities.ts new file mode 100644 index 000000000..b0e4c18b9 --- /dev/null +++ b/packages/auth/src/yielded/identities.ts @@ -0,0 +1,115 @@ +import { Crypto, Effect } from "effect" +import { Base64Url } from "effect/encoding" +import { SqlClient } from "effect/sql" + +import { oauthNamespace } from "./auth.ts" + +/** + * Links a GitHub or Google account to an existing Hazel user, so that signing in with it + * resolves to that user instead of starting a registration. Used to carry users over + * from Clerk (their linked accounts are imported ahead of time) and to match a + * provider-verified email to an existing account at sign-in. + * + * Writes the same three rows as a registration through the library: the identity's + * owner, its login credential and the matching authority row. + */ + +/** The issuers the library records for each provider. */ +export const providerIssuers = { + github: "https://github.com/login/oauth", + google: "https://accounts.google.com", +} as const + +export type Provider = keyof typeof providerIssuers + +export interface ExternalIdentity { + readonly provider: Provider + /** GitHub's numeric user id, or Google's `sub`. */ + readonly subject: string +} + +const encoder = new TextEncoder() + +/** + * The library's identity key (`oauthIdentityKey` in @yielded/auth-persistence, not + * exported): SHA-256 over length-prefixed UTF-8 fields. Tested against keys the library + * writes itself; yielded-dev/auth#200 asks for an exported import path. + */ +export const identityKey = (identity: ExternalIdentity) => + Effect.gen(function* () { + const fields = [ + "effect-auth/oauth-identity-key/v1", + identity.provider, + providerIssuers[identity.provider], + identity.subject, + ].map((field) => encoder.encode(field)) + + const packed = new Uint8Array(fields.reduce((size, value) => size + 4 + value.length, 0)) + const view = new DataView(packed.buffer) + let offset = 0 + for (const value of fields) { + view.setUint32(offset, value.length, false) + packed.set(value, offset + 4) + offset += 4 + value.length + } + + const crypto = yield* Crypto.Crypto + return "v1:" + Base64Url.encode(yield* crypto.digest("SHA-256", packed)) + }) + +export type LinkResult = + | { readonly _tag: "Linked" } + | { readonly _tag: "AlreadyLinked" } + /** The account already belongs to another Hazel user; nothing was written. */ + | { readonly _tag: "Conflict"; readonly ownerId: string } + +/** Idempotent: re-linking the same account to the same user is a no-op. */ +export const linkIdentity = (input: ExternalIdentity & { readonly userId: string }) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const crypto = yield* Crypto.Crypto + const key = yield* identityKey(input) + const credentialId = yield* crypto.randomUUIDv4 + + return yield* sql.withTransaction( + Effect.gen(function* () { + const inserted = yield* sql<{ identityKey: string }>` + insert into auth_oauth_identities ("identityKey", provider, issuer, "externalSubject", "subjectId") + values (${key}, ${input.provider}, ${providerIssuers[input.provider]}, ${input.subject}, ${input.userId}::uuid) + on conflict ("identityKey") do nothing + returning "identityKey"` + + if (inserted.length === 0) { + const [owner] = yield* sql<{ subjectId: string }>` + select "subjectId"::text as "subjectId" from auth_oauth_identities where "identityKey" = ${key}` + return owner?.subjectId === input.userId + ? ({ _tag: "AlreadyLinked" } as const) + : ({ _tag: "Conflict", ownerId: owner?.subjectId ?? "" } as const) + } + + yield* sql` + insert into auth_credentials ("credentialId", "subjectId", revision, active) + values (${credentialId}, ${input.userId}::uuid, 'initial', true)` + yield* sql` + insert into auth_oauth_logins ("moduleId", "credentialId", "subjectId", "identityKey", "credentialRevision", status) + values (${oauthNamespace}, ${credentialId}, ${input.userId}::uuid, ${key}, 'initial', 'active')` + return { _tag: "Linked" } as const + }), + ) + }) + +/** + * The one active person (not a bot) with this email, compared case-insensitively. + * `undefined` when there is none or more than one: an ambiguous match never links. + */ +export const findUserByEmail = (email: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const rows = yield* sql<{ id: string }>` + select id::text as id from users + where lower(email) = lower(${email}) and "deletedAt" is null and "userType" = 'user' + limit 2` + return rows.length === 1 ? rows[0]!.id : undefined + }) + +export const isProvider = (value: string): value is Provider => value === "github" || value === "google" diff --git a/packages/auth/src/yielded/index.ts b/packages/auth/src/yielded/index.ts index db30c15c1..8d0b00719 100644 --- a/packages/auth/src/yielded/index.ts +++ b/packages/auth/src/yielded/index.ts @@ -5,6 +5,16 @@ */ export * as AccessToken from "./access-token.ts" export { HazelAuth, requirement } from "./auth.ts" +export { + type ExternalIdentity, + findUserByEmail, + identityKey, + isProvider, + type LinkResult, + linkIdentity, + type Provider, + providerIssuers, +} from "./identities.ts" export { Claims, HazelAuthApi, Registration } from "./contract.ts" export { CryptoLive, diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts index 40953089f..970765bb1 100644 --- a/packages/auth/src/yielded/server.ts +++ b/packages/auth/src/yielded/server.ts @@ -48,13 +48,54 @@ export interface HazelAuthConfig { readonly returnTargets: ReadonlyArray /** Path on the web app where the callback sends a first-time user to pick their name. */ readonly registrationPath: string + /** + * Path on the web app that starts sign-in again with `?provider=`. The callback sends + * users there when their provider account was just linked to an existing Hazel account + * by verified email, so the next sign-in lands in that account. + */ + readonly continuePath: string } +/** The provider of a registration intent whose identity now has an owner, if any. */ +const linkedProvider = (sql: SqlClient.SqlClient, reference: string) => + Effect.gen(function* () { + const rows = yield* sql<{ provider: string }>` + select i.provider from auth_oauth_registrations r + join auth_oauth_identities i on i."identityKey" = r."identityKey" + where r.reference = ${reference} + limit 1` + return rows[0]?.provider + }).pipe( + Effect.catchTag("SqlError", (error) => + Effect.as(Effect.logWarning("[auth] could not check for a linked account", error), undefined), + ), + ) + /** The browser session cookie: `__Host-` prefixed whenever the API is served over HTTPS. */ export const sessionCookieName = (origin: string) => new URL(origin).protocol === "https:" ? "__Host-effect-auth-session" : "effect-auth-session" -export const makeHazelAuthHttp = (config: HazelAuthConfig) => { +/** + * The auth services' own SQL client, held for the OAuth callback's redirect hook. The + * library runs that hook under its request handling context, which need not include + * application services; this keeps it independent of how the routes are composed. + */ +const makeSqlHandle = () => { + let client: SqlClient.SqlClient | undefined + return { + layer: Layer.effectDiscard( + Effect.map(Effect.service(SqlClient.SqlClient), (sql) => { + client = sql + }), + ), + get: () => client, + } +} + +export const makeHazelAuthHttp = ( + config: HazelAuthConfig, + sqlHandle: { readonly get: () => SqlClient.SqlClient | undefined } = { get: () => undefined }, +) => { const secure = new URL(config.origin).protocol === "https:" const github = GitHub.provider({ @@ -84,10 +125,16 @@ export const makeHazelAuthHttp = (config: HazelAuthConfig) => { )(value).pipe(Effect.orDie) // Targets are paths on the web app, which is a different origin from the API. - const target = - "_tag" in result && result._tag === "RegistrationRequired" - ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` - : result.returnTarget + let target = result.returnTarget as string + if ("_tag" in result && result._tag === "RegistrationRequired") { + const sql = sqlHandle.get() + const provider = + sql === undefined ? undefined : yield* linkedProvider(sql, result.reference) + target = + provider === undefined + ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` + : `${config.continuePath}?${new URLSearchParams({ provider })}` + } return new Response(null, { status: 303, @@ -152,7 +199,8 @@ const PasskeyClaimsLive = Layer.effect( ) export const makeHazelAuthLive = (config: HazelAuthConfig) => { - const { http } = makeHazelAuthHttp(config) + const sqlHandle = makeSqlHandle() + const { http } = makeHazelAuthHttp(config, sqlHandle) // One "default" profile; validated by the library at layer build. const passkeyConfig = Passkey.PasskeyConfig.layer({ @@ -181,6 +229,7 @@ export const makeHazelAuthLive = (config: HazelAuthConfig) => { OAuth.OAuthReturnTargets.exactRoutes(config.returnTargets), SimpleWebAuthn.layer, ActionPoliciesLive, + sqlHandle.layer, ).pipe( Layer.provideMerge(Storage), // Postgres stamps session times with its own clock; tolerate it running slightly diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts index 0738ef006..215486428 100644 --- a/packages/auth/src/yielded/sql.ts +++ b/packages/auth/src/yielded/sql.ts @@ -34,9 +34,13 @@ export class AuthSqlDirect extends Context.Service< export const layerRouted: Layer.Layer = Layer.effect( SqlClient.SqlClient, Effect.gen(function* () { + // A process-wide connection (Bun's pool) provided at construction, if any. + const ambient = yield* Effect.serviceOption(AuthSqlConnection) const direct = yield* Effect.serviceOption(AuthSqlDirect) + // The request's connection, else the process-wide one, else a dedicated connection. const acquirer = Effect.serviceOption(AuthSqlConnection).pipe( + Effect.map((current) => Option.orElse(current, () => ambient)), Effect.flatMap( Option.match({ onSome: (connection) => connection.client, diff --git a/packages/auth/src/yielded/storage.ts b/packages/auth/src/yielded/storage.ts index 97ec2d0bd..5f719297e 100644 --- a/packages/auth/src/yielded/storage.ts +++ b/packages/auth/src/yielded/storage.ts @@ -3,8 +3,10 @@ import { AuthPersistence, PersistenceMappingError } from "@yielded/auth-persiste import type { SubjectIdCodec } from "@yielded/auth-persistence/Adapter" import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" import { Context, Crypto, Effect, Layer, Schema } from "effect" +import { SqlClient } from "effect/sql" import { HazelAuth, requirement } from "./auth.ts" +import { findUserByEmail, isProvider, linkIdentity, providerIssuers } from "./identities.ts" import { Registration } from "./contract.ts" import * as T from "./tables.ts" @@ -141,9 +143,39 @@ const registrationJson = Schema.fromJsonString(Registration) const emailFromIntent = (intent: OAuth.OAuthRegistrationIntent) => intent.profile?.email ?? `${intent.identity.subject}@${intent.identity.provider}.invalid` +/** + * A first-time GitHub or Google sign-in whose provider-verified email belongs to exactly + * one existing user is linked to that user (users who signed up through Clerk with + * email). The registration intent is still issued; the callback then sees the link and + * restarts sign-in, which resolves to the existing account. Matching failures only log: + * the user falls back to registering. + */ +const linkByVerifiedEmail = (intent: OAuth.OAuthRegistrationIntent) => + Effect.gen(function* () { + const { issuer, subject } = intent.identity + const provider: string = intent.identity.provider + const email = intent.profile?.email + if (!isProvider(provider) || issuer !== providerIssuers[provider]) return + if (intent.profile?.emailVerified !== true || email === undefined) return + + const userId = yield* findUserByEmail(email) + if (userId === undefined) return + const linked = yield* linkIdentity({ provider, subject, userId }) + yield* Effect.logInfo("[auth] linked a provider account by verified email", { + provider, + userId, + result: linked._tag, + }) + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("[auth] verified-email linking failed; continuing to registration", cause), + ), + ) + export const OAuthStorageLive = Layer.effectContext( Effect.gen(function* () { const crypto = yield* Crypto.Crypto + const sqlClient = yield* SqlClient.SqlClient const uuid = crypto.randomUUIDv4.pipe( Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.allocate", cause })), @@ -240,7 +272,20 @@ export const OAuthStorageLive = Layer.effectContext( }) return Context.make(OAuth.OAuthSignInPersistence, signIn.oauthSignInPersistence).pipe( - Context.add(OAuth.OAuthRegistrationIntents, intents.oauthRegistrationIntents), + Context.add(OAuth.OAuthRegistrationIntents, { + ...intents.oauthRegistrationIntents, + issue: (input, prepare) => + intents.oauthRegistrationIntents + .issue(input, prepare) + .pipe( + Effect.tap(() => + linkByVerifiedEmail(input.intent).pipe( + Effect.provideService(SqlClient.SqlClient, sqlClient), + Effect.provideService(Crypto.Crypto, crypto), + ), + ), + ), + }), Context.add(OAuth.OAuthAccountsPersistence, accounts.oauthAccountsPersistence), Context.add( HazelAuth.strategies.social.registration.RegistrationAuthority, From 7a75aa02b8870c098c282c6934c2533c936ba473 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 11 Oct 2026 01:25:15 +0200 Subject: [PATCH 04/10] chore(auth): prd migration SQL and rollout runbook for Hazel sign-in The auth tables were applied to prd by hand from Drizzle's DDL, since drizzle-kit push would act on unrelated prd drift. The runbook records what is done in prd (schema, 116 imported Clerk accounts, secrets) and what is left before the cutover. Co-Authored-By: Claude Opus 5.5 --- infra/hazel-auth-rollout.md | 47 ++++++ packages/db/sql/2026-10-11-hazel-auth.sql | 195 ++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 infra/hazel-auth-rollout.md create mode 100644 packages/db/sql/2026-10-11-hazel-auth.sql diff --git a/infra/hazel-auth-rollout.md b/infra/hazel-auth-rollout.md new file mode 100644 index 000000000..f403cbbb0 --- /dev/null +++ b/infra/hazel-auth-rollout.md @@ -0,0 +1,47 @@ +# Hazel sign-in rollout (replacing Clerk) + +Hazel's own sign-in (`@hazel/auth/server`, on `@yielded/auth`): GitHub, Google and passkeys, +with sessions in Postgres. The backend serves it alongside Clerk and only turns it on when +`AUTH_GITHUB_CLIENT_ID` is set. + +## Done in prd (2026-10-11) + +- **Schema:** `packages/db/sql/2026-10-11-hazel-auth.sql` applied (the `auth_*` tables plus + `users."authActive"` / `"securityRevision"`). It was applied by hand, not `drizzle-kit push`: + prd has tables the Drizzle schema doesn't declare (e.g. `invitations`), and push would treat + them as renames or drops. Check any future push against a schema-only copy of prd first. +- **Clerk accounts imported:** `apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts --apply` + linked 116 users' GitHub and Google accounts (83 Google, 33 GitHub), with no conflicts. + It's safe to re-run; re-run it right before the cutover to pick up new Clerk sign-ups. +- **Secrets in Infisical `prod`:** `AUTH_BINDING_KEY`, `AUTH_TRANSACTION_KEY`, + `AUTH_ACCESS_TOKEN_KID`, `AUTH_ACCESS_TOKEN_PRIVATE_JWK`. + +## Who moves how (prd, 2026-10-11) + +| Clerk users | Count | On first sign-in | +| -------------------- | ------------------------------------------------------------------- | -------------------------------------------------------- | +| GitHub/Google linked | 116 matched (128 in Clerk; 12 have no Hazel user) | Straight into their account | +| Email only | 532 (517 never signed in via Clerk; 7 active in 90 days; 362 Gmail) | Matched by provider-verified email, via `/auth/continue` | + +Active users in the last 90 days: 60, of whom 53 have GitHub/Google linked. + +## Remaining + +1. **OAuth apps.** Create these, then put the credentials in Infisical `prod`: + - GitHub OAuth App: callback `https://api.hazel.sh/auth/github/callback`. Store as + `AUTH_GITHUB_CLIENT_ID` / `AUTH_GITHUB_CLIENT_SECRET`. + - Google OAuth client (web): redirect URI `https://api.hazel.sh/auth/google/callback`. Store + as `AUTH_GOOGLE_CLIENT_ID` / `AUTH_GOOGLE_CLIENT_SECRET`. + - Setting `AUTH_GITHUB_CLIENT_ID` turns the routes on at the next deploy. Do this only + once the web app is ready. +2. **Web app (`apps/web-foldkit`).** Build these pages: sign-in (GitHub, Google, passkey), + `/auth/register` (name form), `/auth/continue?provider=` (restarts sign-in), and passkey + settings. Then switch the RPC client to cookie credentials, and the Electric and actors + clients to `POST /auth/token`. +3. **Electric proxy and actors.** Verify the access token against + `https://api.hazel.sh/.well-known/jwks.json` instead of Clerk. +4. **Preview stage.** Do one real GitHub and Google sign-in through Hyperdrive before prd. +5. **Organizations, invites and profile editing.** Move these off Clerk. +6. **Cutover.** Re-run the import, deploy with `AUTH_GITHUB_CLIENT_ID` set, then switch the web + app. Keep Clerk read-only for 2–4 weeks, then remove it (middleware fallback, `ClerkSync`, + webhooks, `CLERK_*`). diff --git a/packages/db/sql/2026-10-11-hazel-auth.sql b/packages/db/sql/2026-10-11-hazel-auth.sql new file mode 100644 index 000000000..f1c96142c --- /dev/null +++ b/packages/db/sql/2026-10-11-hazel-auth.sql @@ -0,0 +1,195 @@ +-- Hazel sign-in (@hazel/auth/server): the auth_* tables and two users columns. +-- +-- Additive only, in one transaction; safe to apply while the app runs. Generated from the +-- Drizzle schema (packages/db/src/schema/auth.ts, users.ts) via pg_dump of a pushed +-- database, because `drizzle-kit push` against prd would also act on unrelated drift. +-- Both column defaults are constants, so adding them does not rewrite users. +-- +-- Apply: psql "$DIRECT_DATABASE_URL" -v ON_ERROR_STOP=1 -f packages/db/sql/2026-10-11-hazel-auth.sql + +BEGIN; + +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS "authActive" boolean NOT NULL DEFAULT true; +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS "securityRevision" text NOT NULL DEFAULT 'initial'; + + +CREATE TABLE public.auth_credentials ( + "credentialId" text NOT NULL, + "subjectId" uuid NOT NULL, + revision text NOT NULL, + active boolean NOT NULL +); + +CREATE TABLE public.auth_identifiers ( + "moduleId" text, + "credentialId" text, + namespace text NOT NULL, + value text NOT NULL, + "subjectId" uuid NOT NULL, + revision text NOT NULL, + "verifiedAt" bigint, + active boolean NOT NULL +); + +CREATE TABLE public.auth_oauth_flows ( + "moduleId" text NOT NULL, + "flowId" text NOT NULL, + purpose text NOT NULL, + generation bigint, + provider text, + "callbackId" text, + issuer text, + "responseIssuerMode" text, + "subjectId" uuid, + "stateDigest" text, + "binderVerifier" text, + "binderExpiresAt" bigint, + snapshot text, + "issuedAt" bigint, + "expiresAt" bigint +); + +CREATE TABLE public.auth_oauth_identities ( + "identityKey" text NOT NULL, + provider text NOT NULL, + issuer text NOT NULL, + "externalSubject" text NOT NULL, + "subjectId" uuid NOT NULL +); + +CREATE TABLE public.auth_oauth_logins ( + "moduleId" text NOT NULL, + "credentialId" text NOT NULL, + "subjectId" uuid NOT NULL, + "identityKey" text NOT NULL, + "credentialRevision" text NOT NULL, + status text NOT NULL +); + +CREATE TABLE public.auth_oauth_registrations ( + "moduleId" text NOT NULL, + reference text NOT NULL, + "flowId" text NOT NULL, + "identityKey" text, + snapshot text, + "expiresAt" bigint, + "retentionUntil" bigint +); + +CREATE TABLE public.auth_passkey_credentials ( + "credentialId" text NOT NULL, + "subjectId" uuid NOT NULL, + "rpId" text NOT NULL, + "protocolCredentialId" text NOT NULL, + "credentialKey" text NOT NULL, + "userHandle" text NOT NULL, + "publicKey" text NOT NULL, + algorithm bigint NOT NULL, + profile text NOT NULL, + "credentialRevision" text NOT NULL, + active boolean NOT NULL, + "primarySignIn" boolean NOT NULL, + "enrollmentUserVerified" boolean NOT NULL, + "backupEligible" boolean NOT NULL, + "backupState" boolean NOT NULL, + counter bigint NOT NULL, + name text NOT NULL, + "createdAt" bigint NOT NULL, + "lastUsedAt" bigint +); + +CREATE TABLE public.auth_passkey_flows ( + "moduleId" text NOT NULL, + "flowId" text NOT NULL, + purpose text NOT NULL, + snapshot text NOT NULL, + "requestBindingVerifier" text NOT NULL, + "requestBindingExpiresAt" bigint NOT NULL, + "issuedAt" bigint NOT NULL, + "expiresAt" bigint NOT NULL +); + +CREATE TABLE public.auth_pending ( + "moduleId" text NOT NULL, + kind text NOT NULL, + digest text NOT NULL, + version text NOT NULL, + "flowId" text NOT NULL, + "subjectId" uuid NOT NULL, + "bindingDigest" text NOT NULL, + snapshot text NOT NULL, + "expiresAt" bigint NOT NULL, + "attemptLimit" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + consumed boolean NOT NULL +); + +CREATE TABLE public.auth_sessions ( + "sessionId" text NOT NULL, + "subjectId" uuid NOT NULL, + digest text NOT NULL, + "securityRevision" text NOT NULL, + "issuedAt" bigint NOT NULL, + "expiresAt" bigint NOT NULL, + "absoluteExpiresAt" bigint NOT NULL, + record text NOT NULL +); + +ALTER TABLE ONLY public.auth_credentials + ADD CONSTRAINT "auth_credentials_credentialId_unique" UNIQUE ("credentialId"); + +ALTER TABLE ONLY public.auth_credentials + ADD CONSTRAINT auth_credentials_subject_credential UNIQUE ("subjectId", "credentialId"); + +ALTER TABLE ONLY public.auth_identifiers + ADD CONSTRAINT auth_identifiers_namespace_value UNIQUE (namespace, value); + +ALTER TABLE ONLY public.auth_oauth_flows + ADD CONSTRAINT auth_oauth_flows_module_flow UNIQUE ("moduleId", "flowId"); + +ALTER TABLE ONLY public.auth_oauth_flows + ADD CONSTRAINT "auth_oauth_flows_stateDigest_unique" UNIQUE ("stateDigest"); + +ALTER TABLE ONLY public.auth_oauth_identities + ADD CONSTRAINT auth_oauth_identities_pkey PRIMARY KEY ("identityKey"); + +ALTER TABLE ONLY public.auth_oauth_logins + ADD CONSTRAINT "auth_oauth_logins_identityKey_unique" UNIQUE ("identityKey"); + +ALTER TABLE ONLY public.auth_oauth_logins + ADD CONSTRAINT auth_oauth_logins_pkey PRIMARY KEY ("credentialId"); + +ALTER TABLE ONLY public.auth_oauth_registrations + ADD CONSTRAINT auth_oauth_registrations_module_flow UNIQUE ("moduleId", "flowId"); + +ALTER TABLE ONLY public.auth_oauth_registrations + ADD CONSTRAINT auth_oauth_registrations_module_reference UNIQUE ("moduleId", reference); + +ALTER TABLE ONLY public.auth_passkey_credentials + ADD CONSTRAINT "auth_passkey_credentials_credentialId_unique" UNIQUE ("credentialId"); + +ALTER TABLE ONLY public.auth_passkey_credentials + ADD CONSTRAINT "auth_passkey_credentials_credentialKey_unique" UNIQUE ("credentialKey"); + +ALTER TABLE ONLY public.auth_passkey_flows + ADD CONSTRAINT auth_passkey_flows_module_flow UNIQUE ("moduleId", "flowId"); + +ALTER TABLE ONLY public.auth_pending + ADD CONSTRAINT auth_pending_digest_unique UNIQUE (digest); + +ALTER TABLE ONLY public.auth_sessions + ADD CONSTRAINT auth_sessions_digest_unique UNIQUE (digest); + +ALTER TABLE ONLY public.auth_sessions + ADD CONSTRAINT "auth_sessions_sessionId_unique" UNIQUE ("sessionId"); + +CREATE INDEX auth_oauth_identities_subject_idx ON public.auth_oauth_identities USING btree ("subjectId"); + +CREATE INDEX auth_oauth_logins_owner_idx ON public.auth_oauth_logins USING btree ("identityKey", "subjectId"); + +CREATE INDEX auth_passkey_credentials_subject_idx ON public.auth_passkey_credentials USING btree ("subjectId"); + +CREATE INDEX auth_sessions_subject_idx ON public.auth_sessions USING btree ("subjectId"); + + +COMMIT; From 99572dfc063b9d2ca6b48dcd910965213c7b15cb Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 17:35:57 +0200 Subject: [PATCH 05/10] chore(auth): update @yielded/auth to beta.38 and Effect to 4.0.4 beta.38 runs persistence initialization on the invoking request's SQL connection, so the dedicated-connection fallback for startup schema checks on Workers is gone. Crypto uses Portable.layerWithoutKdf. Effect 4.0.4 makes Crypto.randomUUIDv4 a function. Co-Authored-By: Claude Opus 5.5 --- apps/backend/package.json | 2 +- apps/backend/src/worker/platform.ts | 4 +- bun.lock | 256 +++++++------------ package.json | 18 +- packages/auth/package.json | 10 +- packages/auth/src/yielded/hazel-auth.test.ts | 16 +- packages/auth/src/yielded/identities.ts | 2 +- packages/auth/src/yielded/index.ts | 2 +- packages/auth/src/yielded/server.ts | 7 +- packages/auth/src/yielded/sql.ts | 27 +- packages/auth/src/yielded/storage.ts | 10 +- 11 files changed, 127 insertions(+), 227 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 487ab3a57..d7a2d518c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,7 +38,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", - "@yielded/auth": "0.1.0-beta.32", + "@yielded/auth": "0.1.0-beta.38", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", diff --git a/apps/backend/src/worker/platform.ts b/apps/backend/src/worker/platform.ts index be8df5375..4038a1353 100644 --- a/apps/backend/src/worker/platform.ts +++ b/apps/backend/src/worker/platform.ts @@ -3,7 +3,7 @@ * caches, `ConfigProvider.fromEnv`), rebuilt over Cloudflare bindings. */ import type { KVNamespace } from "@cloudflare/workers-types" -import { AuthSqlConnection, layerDirect as authSqlDirect, makeRequestConnection } from "@hazel/auth/server" +import { AuthSqlConnection, makeRequestConnection } from "@hazel/auth/server" import { Database } from "@hazel/db" import { layerKvResultPersistence } from "@hazel/effect-cloudflare" import { readHazelDbBinding } from "@hazel/infra/cloudflare" @@ -40,8 +40,6 @@ export const requestPlatformLive = (env: Record, botGateways: B BotGatewayTransport.layerDurableObject(botGateways), // Suspended: init also runs at plan time, where the KV binding does not exist yet. Layer.suspend(() => layerKvResultPersistence(cacheNamespace(env))), - // Auth storage's startup schema checks run outside any request's connection. - Layer.suspend(() => authSqlDirect(Redacted.make(hazelDbConnectionString(env)))), workerEnvLayer(env), ) diff --git a/bun.lock b/bun.lock index 9211ff6ed..451a5f00b 100644 --- a/bun.lock +++ b/bun.lock @@ -55,7 +55,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", - "@yielded/auth": "0.1.0-beta.32", + "@yielded/auth": "0.1.0-beta.38", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", @@ -511,11 +511,11 @@ "@hazel/effect-bun": "workspace:*", "@hazel/schema": "workspace:*", "@simplewebauthn/server": "^14.0.1", - "@yielded/auth": "0.1.0-beta.32", - "@yielded/auth-persistence": "0.1.0-beta.32", - "@yielded/auth-simplewebauthn": "0.1.0-beta.32", - "@yielded/crypto": "0.1.0-beta.32", - "@yielded/jose": "0.1.0-beta.32", + "@yielded/auth": "0.1.0-beta.38", + "@yielded/auth-persistence": "0.1.0-beta.38", + "@yielded/auth-simplewebauthn": "0.1.0-beta.38", + "@yielded/crypto": "0.1.0-beta.38", + "@yielded/jose": "0.1.0-beta.38", "effect": "catalog:effect", "jose": "^6.1.3", "tldts": "^7.4.11", @@ -710,15 +710,15 @@ "alchemy": "2.0.0-beta.80", }, "effect": { - "@effect/ai-openrouter": "4.0.1", - "@effect/atom-react": "4.0.1", - "@effect/opentelemetry": "4.0.1", - "@effect/platform-browser": "4.0.1", - "@effect/platform-bun": "4.0.1", - "@effect/platform-node": "4.0.1", - "@effect/sql-pg": "4.0.1", - "@effect/vitest": "4.0.1", - "effect": "4.0.1", + "@effect/ai-openrouter": "4.0.4", + "@effect/atom-react": "4.0.4", + "@effect/opentelemetry": "4.0.4", + "@effect/platform-browser": "4.0.4", + "@effect/platform-bun": "4.0.4", + "@effect/platform-node": "4.0.4", + "@effect/sql-pg": "4.0.4", + "@effect/vitest": "4.0.4", + "effect": "4.0.4", }, }, "packages": { @@ -1070,31 +1070,31 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-l9vVp3ag8/N+OmPD/SP86MKuJrLxpZ5U2ItM6jPO/Al8njQZ4uEWRQZ72byVH7qumaWsN7cSd7wTCR8C9epBAw=="], + "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.4", "", { "peerDependencies": { "effect": "^4.0.4" } }, "sha512-8xt2Y0VMZ8KMuflHR+S9jTVEq1TijIIF1wmexoh+6bNc6ggbAv3KnGbft0R/s0M72nMrD5xc183+7qS5nl7teA=="], - "@effect/atom-react": ["@effect/atom-react@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1", "react": ">=19.0.0 <20.0.0", "scheduler": ">=0.25.0 <0.28.0" } }, "sha512-V/vcbFCVY2AV7PvjhmOP37Cr4NofrtxBBGW6M1ckBIBxmpi3sMwdPTxj2obPNE4Ho3gkeEQzRC9zEIQ4BDNdQg=="], + "@effect/atom-react": ["@effect/atom-react@4.0.4", "", { "peerDependencies": { "effect": "^4.0.4", "react": ">=19.0.0 <20.0.0", "scheduler": ">=0.25.0 <0.29.0" } }, "sha512-/cd4zHAS/j4QEAJahvIuvCjASI9iKPsKoYDJF1WdqBH6ol+7PIzV5jfIxyd5K0hTlLlavZnzEfi849pcS84apg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.1" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-MZ7M1a1dxMqTqeNwIGSECjO7RrReEgeEwHo8BnbOqP+8IfVpdiJi/iPCmkFVWlAxanMl4qhsq69e0Hkul9t+bA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.4", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.4" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-tj2uchUghlc5cxrv4pvtGLNdLweTgchtBa8dEMNxl4jlMF8783+B9Iq95tMvfUqEmE3DCWYIsLe/REkGTCknKQ=="], "@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.17" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="], - "@effect/platform-browser": ["@effect/platform-browser@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-RsnobWEnffdkRH/wZ3ARIAgMxtGLyfrD0R3iVS+dwQCdYrSbotlJHEoVHEgzcOn5RzMsHG6FHkfdgRMwvcM7kg=="], + "@effect/platform-browser": ["@effect/platform-browser@4.0.4", "", { "peerDependencies": { "effect": "^4.0.4" } }, "sha512-2D+0/Vd+6gMpgR608OPYON4+QMvtbXyl2+HKIlayuXLmlVokI2KoIt37gm01CN+fyRFotBr3hSz4gzLd7xb3Hw=="], - "@effect/platform-bun": ["@effect/platform-bun@4.0.1", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.1" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-m+QR4bp39ZWujCuEsUhZADIYIOFZcUHHlmrixENZqZRbEYgaPLMUWiPrRJ69CM4BuuHW8oCOxfMGUU7oSw/o5g=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.4", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.4" }, "peerDependencies": { "effect": "^4.0.4" } }, "sha512-L5xA9sF2RET7rqi18Q8yUcWq301o+jBMm9GWJixmJWGwzeipzMqjjPk+10vE8vUiHPbtdtJaBSIgs0AJ2jEF1g=="], - "@effect/platform-node": ["@effect/platform-node@4.0.1", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.1", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.1", "redis": ">=5.0.0 <7.0.0" } }, "sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ=="], + "@effect/platform-node": ["@effect/platform-node@4.0.4", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.4", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.4", "redis": ">=5.0.0 <7.0.0" } }, "sha512-SdoyQMrO5zmqBebb2fxDk0sHYyJjBiw4QmLdV2ZcWf3ATpQPge3B5sTLijZg49PadekOwNeO83o5TfdtiiFEig=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.1", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.4", "", { "dependencies": { "@types/ws": "^8.18.2", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.4" } }, "sha512-gh2KRmqGWrJ0FVL8GqnI1GpB+fCfCUI7sGiUZkxxBHyHj1t94rm6VDJffCfuVZWe6GpvYXtizifVczUrgOce8A=="], "@effect/rpc": ["@effect/rpc@0.73.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.18" } }, "sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw=="], "@effect/sql-d1": ["@effect/sql-d1@4.0.1", "", { "dependencies": { "@cloudflare/workers-types": "^5.20260926.1" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-//VXATQFso9/y9JFDIfxxctMEwDlZq6RdlKA+q8nXE1N3q24FdBM9NF8BaBHT1G2NY+CB0U0PjqUASgsmcOvXw=="], - "@effect/sql-pg": ["@effect/sql-pg@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-X5aqOxHNWqVXL+vyTQZLYDip65lm5rrZHuNkWyyr672iup1tjYAs5Dn9tHR5h1t0blpJ57j8Ng5G5AUQ8teIqA=="], + "@effect/sql-pg": ["@effect/sql-pg@4.0.4", "", { "peerDependencies": { "effect": "^4.0.4" } }, "sha512-41x5O2C+gRF3A/Sf+IkV+OOzo5BexsIubH1cib3m4NwhZcjQUQUrq5YbOw7fIuWmBODtAyHNXFjaeEtiXoPL9Q=="], "@effect/sql-sqlite-do": ["@effect/sql-sqlite-do@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-tS+qQok3xKsycnnhvIDX+6nvlM4Y2ouvru3voOzxtDQqX96NSOMX2xReLf4Feb6GN3jnzgEEtVGDDWk3YLrpsg=="], - "@effect/vitest": ["@effect/vitest@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1", "vitest": ">=5.0.0 <6.0.0" } }, "sha512-DbY9kf/018zBxRIX7rJwBnPVPzSarnUpcshm0vfYPFiDkSa7AFrXTh2BlDNUbrdrmqcIOBhVZ7GkEtxf8WiQrg=="], + "@effect/vitest": ["@effect/vitest@4.0.4", "", { "peerDependencies": { "effect": "^4.0.4", "vitest": ">=5.0.0 <6.0.0" } }, "sha512-l5KN46RbDVpqZxWzwXZtX6FVQiyzd+/hTYqL543uSDRo7hC0m19IeICh6O9U/nqCtfersqsNZnBjgHFfkuHsBw=="], "@electric-sql/client": ["@electric-sql/client@1.5.28", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-b0cym3YWXL6PbQmEF0xDRsLt2xgXX46stvOSRAIXRPAP6dLZIHZL0o0HxeTdsZ27L3PbIg6Pw6KnyanRVGtWPA=="], @@ -1352,8 +1352,6 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], - "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], @@ -1520,7 +1518,7 @@ "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], - "@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw=="], @@ -2006,35 +2004,35 @@ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.3", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.1", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-pHDVHqFv26JNC8I500JZ0H4h1kvSyiE3V9gjEO9pRAgD1KrIdJvcHCokV6f7gG7Rx4vMOD11V8VUOpqdyGbKBw=="], @@ -2360,8 +2358,6 @@ "@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], - "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], - "@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -2394,7 +2390,7 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -2448,17 +2444,17 @@ "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], - "@yielded/auth": ["@yielded/auth@0.1.0-beta.32", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.32", "@yielded/jose": "0.1.0-beta.32", "@yielded/oauth": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-HnXi2oJmtaLbmvNaaymx0mWyyIalmIpCAYa1+8OErq2ABFZX1YeUOo03bC9Rx2yb04kA7wNUQPHlSoPoLbjnuA=="], + "@yielded/auth": ["@yielded/auth@0.1.0-beta.38", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.38", "@yielded/jose": "0.1.0-beta.38", "@yielded/oauth": "0.1.0-beta.38" }, "peerDependencies": { "effect": "^4.0.3" } }, "sha512-0SIaHejXG2bz7W6mrV/zpZP4Nd+Q4ZvLwwOnMu59T4RmNa0MCfq1rlDXErXOpTJGZfM/c0by4lVreQ2lTvkGbA=="], - "@yielded/auth-persistence": ["@yielded/auth-persistence@0.1.0-beta.32", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.32", "@yielded/crypto": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-WzJUn1K6+2PK+VNq1RiTJJPvKmju6UvQzoP2Vas4g9HIoO/dk19nphkYAnaYXBj681U3mXw/yyqNVDiTNx+XXA=="], + "@yielded/auth-persistence": ["@yielded/auth-persistence@0.1.0-beta.38", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.38", "@yielded/crypto": "0.1.0-beta.38" }, "peerDependencies": { "effect": "^4.0.3" } }, "sha512-RUIr7idrupFaLfDYZUyrjfZxCNxTJLulFCW82R01CPTlGpcfc2gquh7X0+XS40RVydiz2btATipUszwjh3JN7Q=="], - "@yielded/auth-simplewebauthn": ["@yielded/auth-simplewebauthn@0.1.0-beta.32", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.32", "tldts": "7.4.11" }, "peerDependencies": { "@simplewebauthn/browser": ">=14.0.0 <15", "@simplewebauthn/server": ">=14.0.1 <15", "effect": "^4.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server"] }, "sha512-DTbsU5PjPrubT46huO/w/JhKl0+NBkTI0tEdkr+uAV5xJzWm5+9pdDNS+kYhNAoTwPwlvlCK6AsDT2vZRIgYgA=="], + "@yielded/auth-simplewebauthn": ["@yielded/auth-simplewebauthn@0.1.0-beta.38", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.38", "tldts": "7.4.11" }, "peerDependencies": { "@simplewebauthn/browser": ">=14.0.0 <15", "@simplewebauthn/server": ">=14.0.1 <15", "effect": "^4.0.3" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server"] }, "sha512-2eFOoz6kCx2VCZd8DmUqbmyaansWsn6BJYd/emgps/XiI40v4+VKrZz1HYIRHsRwMX0iQAuFkq2owydaXOtb8Q=="], - "@yielded/crypto": ["@yielded/crypto@0.1.0-beta.32", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-m3N1s94E0RPqgCFqHtQmBq3GQiXApzb2rYJOkTEtAyqtj9Yv22FWBdEUT7s5Y/lR3YbQK0JReymp/Saj0Kwu5g=="], + "@yielded/crypto": ["@yielded/crypto@0.1.0-beta.38", "", { "peerDependencies": { "effect": "^4.0.3" } }, "sha512-kycPGD3Fx8rsR1ykbUFTpm+FIxVdnxUM11A4LjHZM7oxYXBKNSpeipdMtZT74VTRAKvNk/Y9CskCc/p8TlhryQ=="], - "@yielded/jose": ["@yielded/jose@0.1.0-beta.32", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-bOtE4GJldDM+Mcb5UirTfNapRPmRJ4RPic/XcPamIdpqI0G/37DcaIMlyVX6Jc1xQFExVW7mQsl9VO61F3e9LA=="], + "@yielded/jose": ["@yielded/jose@0.1.0-beta.38", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.38" }, "peerDependencies": { "effect": "^4.0.3" } }, "sha512-AXSuwMI7Z85fdSOMwv3mpUN2mK0S+fGkgyjuEO5KBXnQbShiLGkr0a0TPn3dBnzTOcv9JafSXL4iUZcofH5MnA=="], - "@yielded/oauth": ["@yielded/oauth@0.1.0-beta.32", "", { "dependencies": { "@yielded/jose": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-4vCkTwY0Cs3wixMAGU6KywFuVG+m0QuuH8lV166wXCSPbk24DKAK/faZqtiVmwwpRVjJSeonvcGNI4II01mzHw=="], + "@yielded/oauth": ["@yielded/oauth@0.1.0-beta.38", "", { "dependencies": { "@yielded/jose": "0.1.0-beta.38" }, "peerDependencies": { "effect": "^4.0.3" } }, "sha512-0f5JtRKSdYQ2qGhvxh4tgdCf/7kXHpMnbNQM1CZYXxQ+iSQeg6uTvVXcYHgUnng2AXPbrLSwA/zvdDLfwAsrgw=="], "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], @@ -2688,8 +2684,6 @@ "cluster": ["cluster@workspace:apps/cluster"], - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], - "cobe": ["cobe@0.6.5", "", { "dependencies": { "phenomenon": "^1.6.0" } }, "sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], @@ -2744,7 +2738,7 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "crossws": ["crossws@0.4.4", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="], + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], @@ -2778,8 +2772,6 @@ "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug-logfmt": ["debug-logfmt@1.4.6", "", { "dependencies": { "@kikobeats/time-span": "~1.0.5", "null-prototype-object": "~1.2.2", "pretty-ms": "~7.0.1" } }, "sha512-j1/FM2sMIG43aVNRe4PZ4h1BgwHeNaU9Eoi3uSmHbc+oZ1bMNu0yVIcAV93/dfiBSfvD9BAKKmyBlb2Y2I5TjA=="], @@ -2806,8 +2798,6 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -2866,7 +2856,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], + "effect": ["effect@4.0.4", "", {}, "sha512-Sr50OeVSC3M5UkoUYUhFf3EULryhGaZZUf6OzDHTvaznOw7HRlxOKdF3/4nvQXHL22StOyMG3Cvo8Bt7xUp+Ng=="], "effect-oxlint": ["effect-oxlint@0.4.0", "", { "dependencies": { "@oxlint/plugins": "^1.85.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-IA3ihFKGK0JoZVSPPgPP58988jj4PZ5/asFOhaSYHwDyRRRbkBJn7FX8RiPi++MFpXQvb+3fQpKdPzOvUK9xPQ=="], @@ -3198,8 +3188,6 @@ "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], - "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ip-regex": ["ip-regex@4.3.0", "", {}, "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q=="], @@ -3412,10 +3400,6 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], - - "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], - "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -3912,10 +3896,6 @@ "redis": ["redis@6.3.0", "", { "dependencies": { "@redis/bloom": "6.3.0", "@redis/client": "6.3.0", "@redis/json": "6.3.0", "@redis/search": "6.3.0", "@redis/time-series": "6.3.0" } }, "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw=="], - "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], - - "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], - "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -3996,7 +3976,7 @@ "rivetkit": ["rivetkit@2.1.6", "", { "dependencies": { "@hono/standard-validator": "^0.1.3", "@hono/zod-openapi": "^1.1.5", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-runner": "2.1.6", "@rivetkit/fast-json-patch": "^3.1.2", "@rivetkit/on-change": "^6.0.2-rc.1", "@rivetkit/sqlite": "^0.1.1", "@rivetkit/sqlite-vfs": "2.1.6", "@rivetkit/traces": "2.1.6", "@rivetkit/virtual-websocket": "2.0.33", "@rivetkit/workflow-engine": "2.1.6", "cbor-x": "^1.6.0", "get-port": "^7.1.0", "hono": "^4.7.0", "invariant": "^2.2.4", "nanoevents": "^9.1.0", "p-retry": "^6.2.1", "pino": "^9.5.0", "tar": "^7.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "@hono/node-server": "^1.14.0", "@hono/node-ws": "^1.1.1", "drizzle-kit": "^0.31.2", "drizzle-orm": "^0.44.2", "eventsource": "^4.0.0", "ws": "^8.0.0" }, "optionalPeers": ["@hono/node-server", "@hono/node-ws", "drizzle-kit", "drizzle-orm", "eventsource", "ws"] }, "sha512-AFe06CTZoAYWwCltxPp1XNilfOgK53Itvbc/lv0uF+aEwFEAPIZFQPBTrVgt7NGZSuiM2l6/hjMv7yuBZQ71sw=="], - "rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], + "rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.1", "", { "dependencies": { "@babel/generator": "8.0.0-rc.1", "@babel/helper-validator-identifier": "8.0.0-rc.1", "@babel/parser": "8.0.0-rc.1", "@babel/types": "8.0.0-rc.1", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.1", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-5E0AiM5RSQhU6cjtkDFWH6laW4IrMu0j1Mo8x04Xo1ALHmaRMs9/7zej7P3RrryVHW/DdZAp85MA7Be55p0iUw=="], @@ -4132,8 +4112,6 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4598,8 +4576,6 @@ "@alchemy.run/cloudflare-runtime/workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "@alchemy.run/node-utils/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], - "@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -4720,10 +4696,6 @@ "@cloudflare/realtimekit-ui/@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], - "@cloudflare/vitest-pool-workers/@vitest/runner": ["@vitest/runner@4.0.8", "", { "dependencies": { "@vitest/utils": "4.0.8", "pathe": "^2.0.3" } }, "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ=="], - - "@cloudflare/vitest-pool-workers/@vitest/snapshot": ["@vitest/snapshot@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw=="], - "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20250906.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "^7.10.0", "workerd": "1.20250906.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ=="], "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.35.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.3", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250906.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.21", "workerd": "1.20250906.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250906.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A=="], @@ -4766,7 +4738,7 @@ "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - "@hazel/effect-bun/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@hazel/effect-bun/@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@hazel/electric-proxy/@electric-sql/client": ["@electric-sql/client@1.5.15", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-8C+mqZu6r68kU/jf63FLuc90M2ejyeTgB/68G0ufX4H2WepQw/NJXrIY3veE+sLrDGL+uyQB+fDStHH30fi8qg=="], @@ -4780,6 +4752,8 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@libsql/isomorphic-ws/@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@libsql/isomorphic-ws/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], "@mdx-js/mdx/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], @@ -4970,8 +4944,6 @@ "@types/express-serve-static-core/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "@types/pg/pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], - "@types/send/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], "@types/serve-static/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], @@ -4988,8 +4960,6 @@ "alchemy/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - "alchemy/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], - "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -5074,8 +5044,6 @@ "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], - "h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], - "h3/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -5162,7 +5130,7 @@ "rivetkit/uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0-rc.1", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.1", "@babel/types": "^8.0.0-rc.1", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w=="], @@ -5228,6 +5196,8 @@ "unstorage/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], + "vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "vite-plugin-pwa/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -5328,38 +5298,6 @@ "@alchemy.run/cloudflare-runtime/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@alchemy.run/node-utils/rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], - - "@alchemy.run/node-utils/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -5550,10 +5488,6 @@ "@cloudflare/realtimekit-ui/@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@cloudflare/vitest-pool-workers/@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "tinyrainbow": "^3.0.3" } }, "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow=="], - - "@cloudflare/vitest-pool-workers/@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], - "@cloudflare/vitest-pool-workers/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "@cloudflare/vitest-pool-workers/miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], @@ -5626,6 +5560,8 @@ "@foldkit/vite-plugin/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.1", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg=="], + "@foldkit/vite-plugin/@effect/platform-node-shared/@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@foldkit/vite-plugin/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@grpc/grpc-js/@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -5634,7 +5570,7 @@ "@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@hazel/effect-bun/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "@hazel/effect-bun/@types/bun/bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -5890,38 +5826,6 @@ "@vitest/coverage-istanbul/magicast/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - "alchemy/rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], - - "alchemy/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], - - "alchemy/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], - - "alchemy/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], - - "alchemy/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], - - "alchemy/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], - - "alchemy/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], - - "alchemy/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], - - "alchemy/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], - - "alchemy/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], - - "alchemy/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], - - "alchemy/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], - - "alchemy/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], - - "alchemy/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], - - "alchemy/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], - - "alchemy/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -6166,8 +6070,6 @@ "tsdown/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.3", "", { "os": "none", "cpu": "arm64" }, "sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ=="], - "tsdown/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.3", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g=="], - "tsdown/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg=="], "tsdown/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "x64" }, "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A=="], @@ -6246,8 +6148,6 @@ "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.3", "", { "os": "none", "cpu": "arm64" }, "sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ=="], - "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.3", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g=="], - "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg=="], "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "x64" }, "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A=="], @@ -6258,6 +6158,40 @@ "vite-plugin-pwa/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], + + "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "vitefu/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vitefu/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -6376,8 +6310,6 @@ "@babel/preset-env/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@cloudflare/vitest-pool-workers/@vitest/runner/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], - "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -6488,6 +6420,8 @@ "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250906.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg=="], + "@foldkit/vite-plugin/@effect/platform-node/@effect/platform-node-shared/@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@grpc/grpc-js/@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@grpc/grpc-js/@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], diff --git a/package.json b/package.json index 62264cc6c..acd38bdec 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,15 @@ ], "catalogs": { "effect": { - "effect": "4.0.1", - "@effect/platform-bun": "4.0.1", - "@effect/platform-browser": "4.0.1", - "@effect/platform-node": "4.0.1", - "@effect/sql-pg": "4.0.1", - "@effect/opentelemetry": "4.0.1", - "@effect/atom-react": "4.0.1", - "@effect/ai-openrouter": "4.0.1", - "@effect/vitest": "4.0.1" + "effect": "4.0.4", + "@effect/platform-bun": "4.0.4", + "@effect/platform-browser": "4.0.4", + "@effect/platform-node": "4.0.4", + "@effect/sql-pg": "4.0.4", + "@effect/opentelemetry": "4.0.4", + "@effect/atom-react": "4.0.4", + "@effect/ai-openrouter": "4.0.4", + "@effect/vitest": "4.0.4" }, "alchemy": { "@cloudflare/workers-types": "4.20260603.1", diff --git a/packages/auth/package.json b/packages/auth/package.json index a832a1b93..e1b22f0c8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -24,11 +24,11 @@ "@hazel/effect-bun": "workspace:*", "@hazel/schema": "workspace:*", "@simplewebauthn/server": "^14.0.1", - "@yielded/auth": "0.1.0-beta.32", - "@yielded/auth-persistence": "0.1.0-beta.32", - "@yielded/auth-simplewebauthn": "0.1.0-beta.32", - "@yielded/crypto": "0.1.0-beta.32", - "@yielded/jose": "0.1.0-beta.32", + "@yielded/auth": "0.1.0-beta.38", + "@yielded/auth-persistence": "0.1.0-beta.38", + "@yielded/auth-simplewebauthn": "0.1.0-beta.38", + "@yielded/crypto": "0.1.0-beta.38", + "@yielded/jose": "0.1.0-beta.38", "effect": "catalog:effect", "jose": "^6.1.3", "tldts": "^7.4.11" diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts index 8f5c514e9..de62bc33f 100644 --- a/packages/auth/src/yielded/hazel-auth.test.ts +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -30,7 +30,7 @@ import * as AccessToken from "./access-token.ts" import { HazelAuth } from "./auth.ts" import { CryptoLive, makeHazelAuthLive } from "./server.ts" import { identityKey, linkIdentity } from "./identities.ts" -import { AuthSqlConnection, AuthSqlDirect, layerDirect, makeRequestConnection } from "./sql.ts" +import { AuthSqlConnection, makeRequestConnection } from "./sql.ts" const DATABASE_URL = process.env.AUTH_TEST_DATABASE_URL const origin = "http://localhost:3003" @@ -131,21 +131,11 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { Layer.provideMerge(Services), Layer.provide(FakeGitHub), Layer.provide(Hooks.LifecycleHooks.empty), - // Counts the dedicated connections opened outside a request connection. - Layer.provide( - Layer.effect( - AuthSqlDirect, - Effect.map(Effect.service(AuthSqlDirect), (direct) => ({ - open: direct.open.pipe(Effect.tap(() => Effect.sync(() => directOpened++))), - })), - ).pipe(Layer.provide(layerDirect(url))), - ), Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layer]), ) let web: ReturnType let connectionsOpened = 0 - let directOpened = 0 /** Each request gets its own lazily-connecting auth client, closed after the response. */ const send = async (request: Request): Promise => { @@ -322,15 +312,13 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { expect((await verify("nope")).status).toBe(401) }, 60_000) - test("after startup's schema checks, storage only uses the request's connection", async () => { + test("storage only uses the request's connection", async () => { githubUserId = 4242 - const direct = directOpened const opened = connectionsOpened const browser = jar() await signInWithGitHub(browser) expect((await call(browser, "GET", "/me")).status).toBe(200) expect(connectionsOpened).toBeGreaterThan(opened) - expect(directOpened).toBe(direct) }, 60_000) test("requests that never touch auth storage open no database connection", async () => { diff --git a/packages/auth/src/yielded/identities.ts b/packages/auth/src/yielded/identities.ts index b0e4c18b9..e6ecc7ed2 100644 --- a/packages/auth/src/yielded/identities.ts +++ b/packages/auth/src/yielded/identities.ts @@ -69,7 +69,7 @@ export const linkIdentity = (input: ExternalIdentity & { readonly userId: string const sql = yield* SqlClient.SqlClient const crypto = yield* Crypto.Crypto const key = yield* identityKey(input) - const credentialId = yield* crypto.randomUUIDv4 + const credentialId = yield* crypto.randomUUIDv4() return yield* sql.withTransaction( Effect.gen(function* () { diff --git a/packages/auth/src/yielded/index.ts b/packages/auth/src/yielded/index.ts index 8d0b00719..6b94f4dc5 100644 --- a/packages/auth/src/yielded/index.ts +++ b/packages/auth/src/yielded/index.ts @@ -24,4 +24,4 @@ export { makeHazelAuthLive, sessionCookieName, } from "./server.ts" -export { AuthSqlConnection, AuthSqlDirect, layerDirect, layerPool, makeRequestConnection } from "./sql.ts" +export { AuthSqlConnection, layerPool, makeRequestConnection } from "./sql.ts" diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts index 970765bb1..a1532dd96 100644 --- a/packages/auth/src/yielded/server.ts +++ b/packages/auth/src/yielded/server.ts @@ -2,7 +2,6 @@ import { Auth, Http, OAuth, Operations, Passkey } from "@yielded/auth" import * as GitHub from "@yielded/auth/GitHub" import * as Google from "@yielded/auth/Google" import * as SimpleWebAuthn from "@yielded/auth-simplewebauthn/Server" -import * as KdfAdmission from "@yielded/crypto/KdfAdmission" import * as PortableCrypto from "@yielded/crypto/Portable" import * as WebCrypto from "@yielded/crypto/WebCrypto" import { Effect, Layer, Option, type Redacted, Schema } from "effect" @@ -16,13 +15,11 @@ import { OAuthStorageLive, Persistence, storage } from "./storage.ts" /** * WebCrypto plus the library's pure-JS XChaCha20-Poly1305 (OAuth state encryption), the - * same on Bun and Workers. There are no passwords, so the KDF is never used. + * same on Bun and Workers. There are no passwords, so there is no KDF. */ export const CryptoLive = Layer.merge( WebCrypto.layerCryptoWeb, - Layer.suspend(() => PortableCrypto.layer(globalThis.crypto.subtle)).pipe( - Layer.provideMerge(KdfAdmission.layer()), - ), + Layer.suspend(() => PortableCrypto.layerWithoutKdf(globalThis.crypto.subtle)), ) export interface Keyring { diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts index 215486428..831e8f1d5 100644 --- a/packages/auth/src/yielded/sql.ts +++ b/packages/auth/src/yielded/sql.ts @@ -17,39 +17,21 @@ export class AuthSqlConnection extends Context.Service< { readonly client: Effect.Effect } >()("@hazel/auth/AuthSqlConnection") {} -/** - * Opens a dedicated connection for a statement that runs without an - * {@link AuthSqlConnection} in scope, closed when the statement's lease ends. - * - * The storage layers check the physical schema (columns, keys, join types) once, under - * the context they were built in rather than the request's. Where connections are per - * request (Workers), those few statements use this instead. - */ -export class AuthSqlDirect extends Context.Service< - AuthSqlDirect, - { readonly open: Effect.Effect } ->()("@hazel/auth/AuthSqlDirect") {} - /** `SqlClient` for auth storage, routed to the current {@link AuthSqlConnection}. */ export const layerRouted: Layer.Layer = Layer.effect( SqlClient.SqlClient, Effect.gen(function* () { // A process-wide connection (Bun's pool) provided at construction, if any. const ambient = yield* Effect.serviceOption(AuthSqlConnection) - const direct = yield* Effect.serviceOption(AuthSqlDirect) - // The request's connection, else the process-wide one, else a dedicated connection. + // The request's connection, else the process-wide one. The storage layers' one-time + // schema checks also run on the first request's connection. const acquirer = Effect.serviceOption(AuthSqlConnection).pipe( Effect.map((current) => Option.orElse(current, () => ambient)), Effect.flatMap( Option.match({ onSome: (connection) => connection.client, - onNone: () => - Option.match(direct, { - onSome: (direct) => direct.open, - onNone: () => - Effect.die(new Error("AuthSqlConnection is not provided for this request")), - }), + onNone: () => Effect.die(new Error("AuthSqlConnection is not provided for this request")), }), ), // Each statement (and each transaction) leases a connection from that client. @@ -82,9 +64,6 @@ const connect = (url: Redacted.Redacted) => Effect.map((client): SqlClient.SqlClient => client), ) -/** Direct connections for hosts that connect per request (Workers). */ -export const layerDirect = (url: Redacted.Redacted) => Layer.succeed(AuthSqlDirect, { open: connect(url) }) - /** * A client for one request that connects on its first statement, closed with `scope`. * Requests that never touch auth storage open no connection. diff --git a/packages/auth/src/yielded/storage.ts b/packages/auth/src/yielded/storage.ts index 5f719297e..b31c879ae 100644 --- a/packages/auth/src/yielded/storage.ts +++ b/packages/auth/src/yielded/storage.ts @@ -177,9 +177,13 @@ export const OAuthStorageLive = Layer.effectContext( const crypto = yield* Crypto.Crypto const sqlClient = yield* SqlClient.SqlClient - const uuid = crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.allocate", cause })), - ) + const uuid = crypto + .randomUUIDv4() + .pipe( + Effect.mapError((cause) => + PersistenceMappingError.make({ operation: "hazel.allocate", cause }), + ), + ) const common = { subject, authority, subjectId, clock: Mapping.clock } From 37f26e4619070060e336e3eed859ca936155035f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 19:21:41 +0200 Subject: [PATCH 06/10] feat(web): sign in with Hazel auth instead of Clerk The Foldkit app now signs in through @yielded/auth on the API: - /sign-in and /sign-up offer GitHub, Google and passkeys - /auth/register lets a first-time user pick their name - /auth/continue finishes sign-in after the callback links an account by email - the page sign-in started from is remembered across the OAuth round trip Requests to the API authenticate with the HttpOnly session cookie. The Electric proxy and Rivet actors get a short-lived access token from POST /auth/token. Organization creation, invitations and profile editing still call clerk-js and move to Hazel RPCs next. Co-Authored-By: Claude Opus 5.5 --- apps/web-foldkit/package.json | 7 +- apps/web-foldkit/scripts/deploy.sh | 1 - apps/web-foldkit/src/app/auth.ts | 38 +++++ apps/web-foldkit/src/app/clerk-loader.ts | 40 ----- apps/web-foldkit/src/app/clerk.test.ts | 23 --- apps/web-foldkit/src/app/clerk.ts | 64 -------- apps/web-foldkit/src/app/command.ts | 8 +- apps/web-foldkit/src/app/subscription.ts | 4 - apps/web-foldkit/src/app/view.ts | 5 +- apps/web-foldkit/src/auth/client.ts | 24 +++ apps/web-foldkit/src/auth/flows.ts | 100 +++++++++++++ apps/web-foldkit/src/auth/return-to.test.ts | 13 ++ apps/web-foldkit/src/auth/return-to.ts | 37 +++++ apps/web-foldkit/src/entry.ts | 7 - apps/web-foldkit/src/main.ts | 9 +- .../src/page/auth/continue/index.ts | 66 +++++++++ .../src/page/auth/continue/message.ts | 8 + .../src/page/auth/continue/model.ts | 8 + apps/web-foldkit/src/page/auth/index.ts | 69 +-------- apps/web-foldkit/src/page/auth/layout.ts | 68 +++++++++ .../src/page/auth/register/command.ts | 19 +++ .../src/page/auth/register/index.ts | 12 ++ .../src/page/auth/register/message.ts | 12 ++ .../src/page/auth/register/model.ts | 16 ++ .../src/page/auth/register/update.ts | 50 +++++++ .../src/page/auth/register/view.ts | 111 ++++++++++++++ apps/web-foldkit/src/page/auth/scene.test.ts | 137 ++++++++++++------ .../src/page/auth/sign-in/command.ts | 23 +++ .../src/page/auth/sign-in/index.ts | 12 ++ .../src/page/auth/sign-in/message.ts | 13 ++ .../src/page/auth/sign-in/model.ts | 16 ++ .../src/page/auth/sign-in/update.ts | 42 ++++++ .../web-foldkit/src/page/auth/sign-in/view.ts | 110 ++++++++++++++ apps/web-foldkit/src/page/join/command.ts | 2 +- apps/web-foldkit/src/page/join/view.ts | 2 +- apps/web-foldkit/src/page/registry.ts | 3 +- apps/web-foldkit/src/page/root/index.ts | 47 ++++-- apps/web-foldkit/src/page/root/model.ts | 3 + apps/web-foldkit/src/page/root/story.test.ts | 51 +++++++ apps/web-foldkit/src/route.test.ts | 24 ++- apps/web-foldkit/src/route.ts | 34 ++++- apps/web-foldkit/src/rpc.ts | 18 ++- apps/web-foldkit/src/session.ts | 4 +- apps/web-foldkit/src/shell/layouts.ts | 4 - apps/web-foldkit/src/vite-env.d.ts | 9 +- apps/web/src/lib/access-token.ts | 61 ++++++++ apps/web/src/lib/auth-fetch.ts | 17 +-- apps/web/src/lib/clerk-token.ts | 32 ---- apps/web/src/lib/electric-fetch.ts | 9 +- apps/web/src/lib/rivet-client.ts | 9 +- apps/web/src/lib/rpc-auth-middleware.ts | 21 +-- .../lib/services/common/rpc-atom-client.ts | 7 +- bun.lock | 7 +- 53 files changed, 1175 insertions(+), 361 deletions(-) create mode 100644 apps/web-foldkit/src/app/auth.ts delete mode 100644 apps/web-foldkit/src/app/clerk-loader.ts delete mode 100644 apps/web-foldkit/src/app/clerk.test.ts delete mode 100644 apps/web-foldkit/src/app/clerk.ts create mode 100644 apps/web-foldkit/src/auth/client.ts create mode 100644 apps/web-foldkit/src/auth/flows.ts create mode 100644 apps/web-foldkit/src/auth/return-to.test.ts create mode 100644 apps/web-foldkit/src/auth/return-to.ts create mode 100644 apps/web-foldkit/src/page/auth/continue/index.ts create mode 100644 apps/web-foldkit/src/page/auth/continue/message.ts create mode 100644 apps/web-foldkit/src/page/auth/continue/model.ts create mode 100644 apps/web-foldkit/src/page/auth/layout.ts create mode 100644 apps/web-foldkit/src/page/auth/register/command.ts create mode 100644 apps/web-foldkit/src/page/auth/register/index.ts create mode 100644 apps/web-foldkit/src/page/auth/register/message.ts create mode 100644 apps/web-foldkit/src/page/auth/register/model.ts create mode 100644 apps/web-foldkit/src/page/auth/register/update.ts create mode 100644 apps/web-foldkit/src/page/auth/register/view.ts create mode 100644 apps/web-foldkit/src/page/auth/sign-in/command.ts create mode 100644 apps/web-foldkit/src/page/auth/sign-in/index.ts create mode 100644 apps/web-foldkit/src/page/auth/sign-in/message.ts create mode 100644 apps/web-foldkit/src/page/auth/sign-in/model.ts create mode 100644 apps/web-foldkit/src/page/auth/sign-in/update.ts create mode 100644 apps/web-foldkit/src/page/auth/sign-in/view.ts create mode 100644 apps/web-foldkit/src/page/root/story.test.ts create mode 100644 apps/web/src/lib/access-token.ts delete mode 100644 apps/web/src/lib/clerk-token.ts diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index 5deaa99a3..ad401d4b4 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -10,13 +10,14 @@ "deploy": "./scripts/deploy.sh" }, "dependencies": { - "@clerk/shared": "4.8.3", "@effect/platform-browser": "catalog:effect", "@electric-sql/client": "1.5.28", "@foldkit/ui": "0.167.0", "@fontsource/inter": "^5.2.8", + "@hazel/auth": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", + "@simplewebauthn/browser": "^14.0.0", "@tanstack/db": "0.11.3", "effect": "catalog:effect", "embla-carousel": "8.6.0", @@ -31,7 +32,9 @@ "prosemirror-view": "^1.42.6", "remend": "^1.2.2", "tailwind-merge": "^3.5.0", - "tailwind-variants": "^3.2.2" + "tailwind-variants": "^3.2.2", + "@yielded/auth": "0.1.0-beta.38", + "@yielded/auth-simplewebauthn": "0.1.0-beta.38" }, "devDependencies": { "@foldkit/devtools": "0.167.0", diff --git a/apps/web-foldkit/scripts/deploy.sh b/apps/web-foldkit/scripts/deploy.sh index 0f68e5a84..157397959 100755 --- a/apps/web-foldkit/scripts/deploy.sh +++ b/apps/web-foldkit/scripts/deploy.sh @@ -6,7 +6,6 @@ export VITE_BACKEND_URL=https://api.hazel.sh export VITE_ELECTRIC_URL=https://electric.hazel.sh/v1/shape export VITE_RIVET_URL=https://rivet.hazel.sh export VITE_R2_PUBLIC_URL=https://cdn.hazel.sh -export VITE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsuaGF6ZWwuc2gk export VITE_PUBLIC_POSTHOG_KEY=phc_ifs2nPyiiVDqk2c4cZO9uQwSertR5TFg19repUZLvfX export VITE_PUBLIC_POSTHOG_HOST=https://ph.hazel.sh export VITE_OTEL_ENVIRONMENT=production diff --git a/apps/web-foldkit/src/app/auth.ts b/apps/web-foldkit/src/app/auth.ts new file mode 100644 index 000000000..d9031eef9 --- /dev/null +++ b/apps/web-foldkit/src/app/auth.ts @@ -0,0 +1,38 @@ +import { Effect, Schedule, Schema } from "effect" +import { clearAccessToken } from "~/lib/access-token" +import { HazelAuthClient } from "../auth/client" +import type { Auth } from "../session" + +/** + * The browser's Hazel session, read from the API (`getSession`). The session itself is an + * HttpOnly cookie on the API host, so the app only learns whether one is valid. + */ + +/** Retried while the API is unreachable; auth stays `Loading` (the app loader) meanwhile. */ +const retryUnavailable = Schedule.min([ + Schedule.exponential("500 millis").pipe(Schedule.jittered), + Schedule.spaced("30 seconds"), +]) + +export const readSession: Effect.Effect = Effect.gen(function* () { + const client = yield* HazelAuthClient + const session = yield* client.auth.getSession() + return session === null ? "SignedOut" : "SignedIn" +}).pipe( + Effect.tapError((error) => Effect.logWarning("[auth] session check failed", error)), + Effect.retry(retryUnavailable), + Effect.orDie, +) + +/** The API rejected sign-out or could not be reached; the user stays signed in. */ +export class SignOutError extends Schema.TaggedError()("SignOutError", { + message: Schema.String, +}) {} + +/** Ends the session on the API, then reloads `/`, which sends the browser to sign-in. */ +export const signOut: Effect.Effect = Effect.gen(function* () { + const client = yield* HazelAuthClient + yield* client.auth.signOut().pipe(Effect.mapError((error) => new SignOutError({ message: error._tag }))) + clearAccessToken() + yield* Effect.sync(() => window.location.assign("/")) +}) diff --git a/apps/web-foldkit/src/app/clerk-loader.ts b/apps/web-foldkit/src/app/clerk-loader.ts deleted file mode 100644 index 8d22976bf..000000000 --- a/apps/web-foldkit/src/app/clerk-loader.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { loadClerkJSScript, loadClerkUIScript } from "@clerk/shared/loadClerkJsScript" -import { Effect, Schema } from "effect" - -/** clerk-js or Clerk's UI failed to download or load; auth stays `Loading`. */ -export class ClerkLoadError extends Schema.TaggedError()("ClerkLoadError", { - message: Schema.String, -}) {} - -interface LoadableClerk { - readonly load: (options: Record) => Promise -} - -const isLoadable = (clerk: unknown): clerk is LoadableClerk => - typeof clerk === "object" && clerk !== null && "load" in clerk && typeof clerk.load === "function" - -const attempt = (run: () => Promise) => - Effect.tryPromise({ try: run, catch: (error) => new ClerkLoadError({ message: String(error) }) }) - -/** - * Downloads clerk-js and Clerk's UI and loads them with legacy ``'s options - * (`apps/web/src/main.tsx`). `app/clerk.ts` then sees `window.Clerk.loaded` and follows it. - */ -export const loadClerk = (publishableKey: string): Effect.Effect => - Effect.gen(function* () { - yield* attempt(() => loadClerkJSScript({ publishableKey })) - yield* attempt(() => loadClerkUIScript({ publishableKey })) - const clerk: unknown = window.Clerk - if (!isLoadable(clerk)) - return yield* new ClerkLoadError({ message: "window.Clerk missing after clerk-js loaded" }) - yield* attempt(() => - clerk.load({ - ui: { ClerkUI: Reflect.get(window, "__internal_ClerkUICtor") }, - afterSignOutUrl: "/", - signInUrl: "/sign-in", - signUpUrl: "/sign-up", - signInFallbackRedirectUrl: "/", - signUpFallbackRedirectUrl: "/", - }), - ) - }) diff --git a/apps/web-foldkit/src/app/clerk.test.ts b/apps/web-foldkit/src/app/clerk.test.ts deleted file mode 100644 index 8f2b68f0b..000000000 --- a/apps/web-foldkit/src/app/clerk.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @vitest-environment jsdom -import { Effect, Exit } from "effect" -import { afterEach, describe, expect, test } from "vitest" -import { signOut } from "./clerk" - -/** `signOut` reports a rejected Clerk call as a typed failure instead of a defect (a crash). */ - -const original = window.Clerk - -afterEach(() => { - window.Clerk = original -}) - -describe("signOut", () => { - test("a rejected clerk.signOut fails with SignOutError", async () => { - Object.assign(window, { Clerk: { signOut: () => Promise.reject(new Error("offline")) } }) - const exit = await Effect.runPromiseExit(signOut) - expect(Exit.isFailure(exit) && Exit.findErrorOption(exit)).toMatchObject({ - _tag: "Some", - value: { _tag: "SignOutError" }, - }) - }) -}) diff --git a/apps/web-foldkit/src/app/clerk.ts b/apps/web-foldkit/src/app/clerk.ts deleted file mode 100644 index f98bb814d..000000000 --- a/apps/web-foldkit/src/app/clerk.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Effect, Queue, Schema, Stream } from "effect" -import type { Auth } from "../session" - -/** - * Auth through `window.Clerk`, the same instance the legacy app and the parity stub use. Clerk may - * load after boot, so the stream polls for it, then follows its listener. - */ - -interface ClerkSession { - readonly session?: unknown -} -interface ListenableClerk { - readonly loaded?: boolean - readonly addListener: (listener: (resources: ClerkSession) => void) => () => void -} -interface SignOutClerk { - readonly signOut: (options?: { redirectUrl?: string }) => Promise -} - -const isListenable = (clerk: object): clerk is ListenableClerk => - "addListener" in clerk && typeof clerk.addListener === "function" -const canSignOut = (clerk: object): clerk is SignOutClerk => - "signOut" in clerk && typeof clerk.signOut === "function" - -const POLL_MS = 50 - -/** `window.Clerk` once it has loaded, polled until then. */ -const loadedClerk: Effect.Effect = Effect.suspend(() => { - const clerk = window.Clerk - return clerk?.loaded && isListenable(clerk) - ? Effect.succeed(clerk) - : Effect.sleep(POLL_MS).pipe(Effect.andThen(loadedClerk)) -}) - -/** `useAuth({ treatPendingAsSignedOut: false }).isSignedIn`: any session counts. */ -export const clerkAuthStream: Stream.Stream = Stream.callback((queue) => - loadedClerk.pipe( - Effect.flatMap((clerk) => - Effect.acquireRelease( - Effect.sync(() => - clerk.addListener(({ session }) => - Queue.offerUnsafe(queue, session ? "SignedIn" : "SignedOut"), - ), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ), - ), - Effect.flatMap(() => Effect.never), - ), -) - -/** `clerk.signOut` rejected (offline); the user stays signed in. */ -export class SignOutError extends Schema.TaggedError()("SignOutError", { - message: Schema.String, -}) {} - -/** `useAuth().logout()`: Clerk signs out and sends the browser to `/`. */ -export const signOut: Effect.Effect = Effect.tryPromise({ - try: async () => { - const clerk = window.Clerk - if (clerk && canSignOut(clerk)) await clerk.signOut({ redirectUrl: "/" }) - }, - catch: (error) => new SignOutError({ message: String(error) }), -}) diff --git a/apps/web-foldkit/src/app/command.ts b/apps/web-foldkit/src/app/command.ts index 2fe2f8114..e7616ca50 100644 --- a/apps/web-foldkit/src/app/command.ts +++ b/apps/web-foldkit/src/app/command.ts @@ -6,7 +6,7 @@ import { applyTheme, ResolvedTheme, saveThemePreference, ThemeCustomization, The import { saveSoundSettings, SoundSettings } from "../notification-sound" import { deliverNotifications } from "./notification-sinks" import { NotificationId } from "@hazel/schema" -import { signOut } from "./clerk" +import { readSession, signOut } from "./auth" import { Message } from "./message" export const NavigateInternal = Command.define("NavigateInternal", { @@ -55,6 +55,12 @@ export const DeliverNotifications = Command.define("DeliverNotifications", { execute: ({ ids }) => deliverNotifications(ids).pipe(Effect.as(Message.CompletedDeliverNotifications())), }) +/** Whether the API has a session for this browser; retried until the API answers. */ +export const CheckSession = Command.define("CheckSession", { + messages: [Message.ChangedAuth], + execute: readSession.pipe(Effect.map((auth) => Message.ChangedAuth({ auth }))), +}) + export const SignOut = Command.define("SignOut", { messages: [Message.CompletedSignOut, Message.FailedSignOut], execute: diff --git a/apps/web-foldkit/src/app/subscription.ts b/apps/web-foldkit/src/app/subscription.ts index d73eeb6ba..909f7c28c 100644 --- a/apps/web-foldkit/src/app/subscription.ts +++ b/apps/web-foldkit/src/app/subscription.ts @@ -19,7 +19,6 @@ import { layoutHotkeys } from "../overlay/hotkeys" import * as Modal from "../overlay/modal" import * as Platform from "../platform" import * as Toasts from "../overlay/toaster" -import { clerkAuthStream } from "./clerk" import { Message } from "./message" import { type Model, pageHostOf, sharedOf } from "./model" @@ -30,9 +29,6 @@ const rootSubscriptions = Subscription.make()((entry) => ({ mapMatches: (isDark) => Message.ChangedSystemTheme({ theme: isDark ? "dark" : "light" }), }), ), - auth: Subscription.persistentEntry( - clerkAuthStream.pipe(Stream.map((auth) => Message.ChangedAuth({ auth }))), - ), // Legacy `presenceNowSignal`: wall clock for deriving stale presence. presenceClock: Subscription.persistentEntry( Stream.concat(Stream.succeed(undefined), Stream.tick("30 seconds")).pipe( diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index 40fea2cf3..7ca9bb815 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -11,7 +11,6 @@ import { formatStatusExpirationAt } from "../shell/menus" import type { ShellContext } from "../shell/context" import { appLoader, - authLayout, type ChannelSettingsTab, channelSettingsLayout, contentColumnLayout, @@ -153,10 +152,8 @@ const appRoot = (h: HtmlBuilder, model: Model, children: ReadonlyArray< const body = (model: Model, h: HtmlBuilder): Html => { const route = model.route if (route._tag === "NotFound") return h.div([h.Id("app")], [`Not found: ${route.path}`]) - if (route._tag === "SignIn" || route._tag === "SignUp") - return appRoot(h, model, [authLayout(h, pageBody(model, h, true))]) if (isPublicRoute(route)) return appRoot(h, model, [pageBody(model, h, false)]) - // `_app/layout.tsx`: a loader until Clerk knows the session; signed out redirects to sign-in. + // `_app/layout.tsx`: a loader until the API answers for the session; signed out redirects to sign-in. if (model.auth !== "SignedIn") return appRoot(h, model, [appLoader(h)]) // `AppShell`: a loader until `user.me` answers. if (model.currentUser === null) return appRoot(h, model, [appLoader(h)]) diff --git a/apps/web-foldkit/src/auth/client.ts b/apps/web-foldkit/src/auth/client.ts new file mode 100644 index 000000000..0c863d162 --- /dev/null +++ b/apps/web-foldkit/src/auth/client.ts @@ -0,0 +1,24 @@ +import { HazelAuthApi } from "@hazel/auth/contract" +import { Client } from "@yielded/auth" +import { Layer } from "effect" +import type { ClientService } from "@yielded/auth/Client" + +/** + * Hazel's sign-in client (`@hazel/auth/contract` over `/auth/*` on the API). The session is + * an HttpOnly cookie on the API host; the client sends credentials and the CSRF header. + */ +export const HazelAuthClient = Client.make(HazelAuthApi, { + baseUrl: import.meta.env.VITE_BACKEND_URL || "http://localhost:3003", +}) + +export type HazelAuthClient = ClientService + +/** Construction only fails on an invalid client configuration, a programming error. */ +export const HazelAuthClientLive = HazelAuthClient.layerFetch.pipe(Layer.orDie) + +/** Sign-in providers the API offers; Google is configured per stage. */ +export const Provider = ["github", "google"] as const +export type Provider = (typeof Provider)[number] + +export const isProvider = (value: string): value is Provider => + (Provider as ReadonlyArray).includes(value) diff --git a/apps/web-foldkit/src/auth/flows.ts b/apps/web-foldkit/src/auth/flows.ts new file mode 100644 index 000000000..d6dfc6a74 --- /dev/null +++ b/apps/web-foldkit/src/auth/flows.ts @@ -0,0 +1,100 @@ +import * as PasskeyBrowser from "@yielded/auth-simplewebauthn/Browser" +import { Effect, Redacted, Schema } from "effect" +import { load } from "foldkit/navigation" +import { HazelAuthClient, type Provider } from "./client" +import { rememberReturnTo, takeReturnTo } from "./return-to" + +/** + * The sign-in flows the auth pages run: OAuth (leaves for the provider), passkeys and + * registration (both end with a session cookie, then a full load so the app starts signed in). + */ + +/** A flow that did not finish. `message` is `null` when the user cancelled it themselves. */ +export class AuthFlowError extends Schema.TaggedError()("AuthFlowError", { + message: Schema.NullOr(Schema.String), +}) {} + +/** User-facing text for a failed auth call, by the failure's tag. */ +export const describeFailure = (tag: string): string | null => { + if (tag === "PasskeyBrowserNotCompleted" || tag === "PasskeyBrowserBusy") return null + if (tag === "PasskeyBrowserUnsupported" || tag === "PasskeyBrowserUnavailable") + return "This browser can't use passkeys. Continue with GitHub or Google instead." + if (tag === "OperationHttpError") return "Couldn't reach Hazel. Check your connection and try again." + if (tag.endsWith("Rejected") || tag.endsWith("ActionRequired")) + return "That sign-in expired or was already used. Please start again." + if (tag.endsWith("Unavailable")) return "Sign-in is unavailable right now. Please try again in a moment." + return "Something went wrong signing you in. Please try again." +} + +const failed = (error: { readonly _tag: string }) => + new AuthFlowError({ message: describeFailure(error._tag) }) + +const newId = Effect.sync(() => crypto.randomUUID()) + +/** After a session is issued: a full load of the remembered page, so the app boots signed in. */ +const enterApp = (returnTo: string | null) => + Effect.gen(function* () { + const remembered = returnTo ?? (yield* takeReturnTo) + yield* load(remembered ?? "/") + }) + +/** Starts OAuth with `provider` and leaves for its consent screen. */ +export const startOAuthSignIn = (provider: Provider, returnTo: string | null) => + Effect.gen(function* () { + if (returnTo !== null) yield* rememberReturnTo(returnTo) + const client = yield* HazelAuthClient + const started = yield* client.auth + .signIn({ provider, returnTarget: "/" }) + .pipe(Effect.mapError(failed)) + yield* load(Redacted.value(started.authorizationUrl)) + }) + +/** Begin, the browser's passkey prompt, complete; then into the app. */ +export const signInWithPasskey = (returnTo: string | null) => + Effect.gen(function* () { + const client = yield* HazelAuthClient + const browser = yield* PasskeyBrowser.make() + const started = yield* client.auth.passkeySignIn({ + flowId: yield* newId, + commandId: yield* newId, + profileId: "default", + }) + const response = yield* browser.authenticate({ started, mediation: "required" }) + const result = yield* client.auth.completePasskeySignIn({ + flowId: response.flowId, + response: Redacted.value(response.response), + }) + if (result._tag !== "Authenticated") return yield* new AuthFlowError({ message: describeFailure("") }) + yield* enterApp(returnTo) + }).pipe( + Effect.catchIf( + (error) => error._tag !== "AuthFlowError", + (error) => Effect.fail(failed(error)), + ), + ) + +export interface RegistrationInput { + readonly flowId: string + readonly reference: string + readonly firstName: string + readonly lastName: string +} + +/** Creates the Hazel account for a verified provider identity; the API signs it in. */ +export const register = (input: RegistrationInput) => + Effect.gen(function* () { + const client = yield* HazelAuthClient + const result = yield* client.auth.register({ + flowId: input.flowId, + reference: input.reference, + commandId: yield* newId, + registration: { firstName: input.firstName.trim(), lastName: input.lastName.trim() }, + }) + if (result._tag !== "Authenticated") return yield* new AuthFlowError({ message: describeFailure("") }) + yield* enterApp(null) + }).pipe( + Effect.catchIf( + (error) => error._tag !== "AuthFlowError", + (error) => Effect.fail(failed(error)), + ), + ) diff --git a/apps/web-foldkit/src/auth/return-to.test.ts b/apps/web-foldkit/src/auth/return-to.test.ts new file mode 100644 index 000000000..4b73147f5 --- /dev/null +++ b/apps/web-foldkit/src/auth/return-to.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest" +import { safeReturnTo } from "./return-to" + +describe("safeReturnTo", () => { + test("keeps same-origin paths and drops everything else", () => { + expect(safeReturnTo("/hazel/chat?x=1")).toBe("/hazel/chat?x=1") + expect(safeReturnTo("//evil.example")).toBeNull() + expect(safeReturnTo("/\\evil.example")).toBeNull() + expect(safeReturnTo("https://evil.example")).toBeNull() + expect(safeReturnTo("")).toBeNull() + expect(safeReturnTo(null)).toBeNull() + }) +}) diff --git a/apps/web-foldkit/src/auth/return-to.ts b/apps/web-foldkit/src/auth/return-to.ts new file mode 100644 index 000000000..d48eae4e6 --- /dev/null +++ b/apps/web-foldkit/src/auth/return-to.ts @@ -0,0 +1,37 @@ +import { Effect } from "effect" + +/** + * Where to go after sign-in. The OAuth callback may only return to `/` (the API allows exact + * return routes), so the page the visitor came from is kept in sessionStorage across the + * provider round trip and taken by the root page once signed in. + */ + +const KEY = "hazel:auth:return-to" + +/** Same-origin paths only: `/x`, never `//host` or an absolute URL. */ +export const safeReturnTo = (value: string | null | undefined): string | null => + value != null && value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\") + ? value + : null + +export const rememberReturnTo = (returnTo: string | null): Effect.Effect => + Effect.sync(() => { + const safe = safeReturnTo(returnTo) + try { + if (safe === null || safe === "/") sessionStorage.removeItem(KEY) + else sessionStorage.setItem(KEY, safe) + } catch { + // Storage unavailable (private mode): sign-in still lands on `/`. + } + }) + +/** The remembered path, removed as it is read so it applies to one sign-in only. */ +export const takeReturnTo: Effect.Effect = Effect.sync(() => { + try { + const value = sessionStorage.getItem(KEY) + sessionStorage.removeItem(KEY) + return safeReturnTo(value) + } catch { + return null + } +}) diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts index 3ce4d53b6..4076328ad 100644 --- a/apps/web-foldkit/src/entry.ts +++ b/apps/web-foldkit/src/entry.ts @@ -2,9 +2,7 @@ import "@fontsource/inter/400.css" import "@fontsource/inter/400-italic.css" import "./styles.css" -import { Effect } from "effect" import { Runtime } from "foldkit" -import { loadClerk } from "./app/clerk-loader" import { galleryComponentOf, startGallery } from "./gallery/boot" import { Flags, flags, init, managedResources, Message, Model, subscriptions, update, view } from "./main" import { ResourcesLive } from "./rpc" @@ -36,8 +34,3 @@ const startApplication = () => const galleryComponent = galleryComponentOf(location.pathname) if (galleryComponent === undefined) startApplication() else startGallery(galleryComponent, container) - -// Parity's Clerk stub is installed before boot; production downloads the real clerk-js. -const clerkKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY -if (galleryComponent === undefined && window.Clerk === undefined && clerkKey) - Effect.runFork(loadClerk(clerkKey).pipe(Effect.tapError(Effect.logError))) diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts index 3c6a076ac..b7defdba1 100644 --- a/apps/web-foldkit/src/main.ts +++ b/apps/web-foldkit/src/main.ts @@ -5,6 +5,7 @@ import { modifyFields } from "foldkit/struct" import { toString as urlToString, type Url } from "foldkit/url" import { ApplyTheme, + CheckSession, FetchCurrentUser, LoadExternal, NavigateInternal, @@ -328,7 +329,13 @@ export const init: Runtime.RoutingApplicationInit(model, [appliedTheme, enteredRoute, informShell, redirect]) + return Update.combine(model, [ + appliedTheme, + enteredRoute, + informShell, + redirect, + (m) => ({ model: m, commands: [CheckSession()] }), + ]) } // UPDATE diff --git a/apps/web-foldkit/src/page/auth/continue/index.ts b/apps/web-foldkit/src/page/auth/continue/index.ts new file mode 100644 index 000000000..8129280aa --- /dev/null +++ b/apps/web-foldkit/src/page/auth/continue/index.ts @@ -0,0 +1,66 @@ +import { Effect, Option, Schema } from "effect" +import { Command, Submodel } from "foldkit" +import { isProvider, Provider } from "../../../auth/client" +import { startOAuthSignIn } from "../../../auth/flows" +import type { RouteOf } from "../../../route" +import { loader } from "../../../ui/loader" +import { definePage, type PageReturn, type PageViewInputs } from "../../contract" +import { authError, authFrame, authHeading, signInLink } from "../layout" +import { Message } from "./message" +import { Model } from "./model" + +/** + * `/auth/continue?provider=`: the OAuth callback just linked this provider account to an + * existing Hazel account (same verified email), so signing in once more lands in it. + */ + +type Return = PageReturn + +const ContinueSignIn = Command.define("ContinueSignIn", { + args: { provider: Schema.Literals(Provider) }, + messages: [Message.LeftForProvider, Message.FailedSignIn], + // The page the visitor started from is still remembered from the first attempt. + execute: ({ provider }) => + startOAuthSignIn(provider, null).pipe( + Effect.as(Message.LeftForProvider()), + Effect.catch((error) => Effect.succeed(Message.FailedSignIn({ message: error.message }))), + ), +}) + +const init = (route: RouteOf<"AuthContinue">): Return => { + const provider = Option.getOrNull(route.provider) + return provider !== null && isProvider(provider) + ? { model: { provider, error: null }, commands: [ContinueSignIn({ provider })] } + : { model: { provider: null, error: null } } +} + +const update = (model: Model, message: Message): Return => + Message.match(message, { + LeftForProvider: () => ({ model }), + FailedSignIn: ({ message }) => ({ + model: { ...model, error: message ?? "Something went wrong signing you in. Please try again." }, + }), + }) + +const view = Submodel.defineView((model, { shared }, h) => + authFrame( + h, + shared.nowMs, + model.provider === null || model.error !== null + ? [ + authHeading(h, "Couldn't finish signing in", "Start again and pick the same account."), + authError(h, model.error), + h.p([h.Class("mt-4")], [signInLink(h, "Back to sign in")]), + ] + : [ + authHeading(h, "Your accounts are linked", "Finishing sign-in..."), + loader(h, { className: "size-6" }), + ], + ), +) + +export const page = definePage( + "AuthContinue", + { Model, Message }, + { routes: ["AuthContinue"], init, update, view }, +) diff --git a/apps/web-foldkit/src/page/auth/continue/message.ts b/apps/web-foldkit/src/page/auth/continue/message.ts new file mode 100644 index 000000000..b63ba0916 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/continue/message.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" + +export const Message = defineMessageUnion({ + LeftForProvider: {}, + FailedSignIn: { message: Schema.NullOr(Schema.String) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/auth/continue/model.ts b/apps/web-foldkit/src/page/auth/continue/model.ts new file mode 100644 index 000000000..d4bb362ce --- /dev/null +++ b/apps/web-foldkit/src/page/auth/continue/model.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" + +export const Model = Schema.Struct({ + /** `null` when `?provider=` is missing or not one the API offers. */ + provider: Schema.NullOr(Schema.String), + error: Schema.NullOr(Schema.String), +}) +export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/auth/index.ts b/apps/web-foldkit/src/page/auth/index.ts index 3fdd5c3eb..fa3e88e95 100644 --- a/apps/web-foldkit/src/page/auth/index.ts +++ b/apps/web-foldkit/src/page/auth/index.ts @@ -1,66 +1,3 @@ -import { Option, Schema } from "effect" -import { Submodel } from "foldkit" -import type { RouteOf } from "../../route" -import { definePage, type PageReturn, type PageViewInputs } from "../contract" -import { ClerkMountMessage, clerkComponent } from "./clerk-mount" - -/** `sign-in/$.tsx` and `sign-up/$.tsx`: Clerk's prebuilt form (the root adds the centered layout). */ - -const Model = Schema.Struct({ redirectUrl: Schema.NullOr(Schema.String) }) -type Model = typeof Model.Type -const Message = ClerkMountMessage -type Message = ClerkMountMessage - -export const init = (route: RouteOf<"SignIn" | "SignUp">): PageReturn => ({ - model: { redirectUrl: Option.getOrNull(route.redirectUrl) }, -}) - -// Mount results need no state change: the form is Clerk's from here on. -export const update = (model: Model): PageReturn => ({ model }) - -const toMessage = (message: ClerkMountMessage): Message => message - -export const signInView = Submodel.defineView((model, _inputs, h) => { - const redirect = model.redirectUrl ?? "/" - return clerkComponent( - h, - "SignIn", - { - routing: "path", - path: "/sign-in", - signUpUrl: "/sign-up", - fallbackRedirectUrl: redirect, - signUpFallbackRedirectUrl: redirect, - }, - toMessage, - ) -}) - -export const signUpView = Submodel.defineView((model, _inputs, h) => { - const redirect = model.redirectUrl ?? "/" - return clerkComponent( - h, - "SignUp", - { - routing: "path", - path: "/sign-up", - signInUrl: "/sign-in", - fallbackRedirectUrl: redirect, - signInFallbackRedirectUrl: redirect, - }, - toMessage, - ) -}) - -// Clerk's own sub-routes (the splat) stay inside one form instance. -export const signInPage = definePage( - "SignIn", - { Model, Message }, - { routes: ["SignIn"], key: () => "SignIn", init, update, view: signInView }, -) - -export const signUpPage = definePage( - "SignUp", - { Model, Message }, - { routes: ["SignUp"], key: () => "SignUp", init, update, view: signUpView }, -) +export { page as continuePage } from "./continue" +export { page as registerPage } from "./register" +export { page as signInPage } from "./sign-in" diff --git a/apps/web-foldkit/src/page/auth/layout.ts b/apps/web-foldkit/src/page/auth/layout.ts new file mode 100644 index 000000000..3a54e7e61 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/layout.ts @@ -0,0 +1,68 @@ +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { AppRoute, hrefOf } from "../../route" +import { backgroundImage, logoContent, panelCard, panelFrame } from "../onboarding/brand-panel" + +/** The auth pages' frame: the pixel-art panel on large screens (as on `/join`), the form beside it. */ + +const homeLink = (h: HtmlBuilder, className: string, children: ReadonlyArray): Html => + h.a([h.Class(className), h.Href(hrefOf(AppRoute.Root()))], [...children]) + +export const authFrame = (h: HtmlBuilder, nowMs: number, content: ReadonlyArray): Html => + h.main( + [h.Class("grid h-dvh grid-cols-1 lg:grid-cols-2")], + [ + panelFrame(h, [ + backgroundImage(h, nowMs, "Background"), + homeLink( + h, + "relative z-20 flex items-center gap-2", + logoContent(h, { logoClassName: "size-8 text-white" }), + ), + panelCard(h, [ + h.p( + [h.Class("text-lg text-white")], + ["A calm place for your team to talk, decide and ship."], + ), + ]), + ]), + h.div( + [h.Class("flex h-full flex-col p-6 lg:p-12")], + [ + h.div( + [h.Class("mb-8 lg:hidden")], + [ + homeLink( + h, + "flex items-center gap-2", + logoContent(h, { logoClassName: "size-8", textClassName: "text-fg" }), + ), + ], + ), + h.div([h.Class("m-auto flex w-full max-w-sm flex-col")], [...content]), + ], + ), + ], + ) + +export const authHeading = (h: HtmlBuilder, title: string, description: string): Html => + h.div( + [h.Class("mb-8 space-y-2")], + [ + h.h1([h.Class("font-semibold text-2xl text-fg")], [title]), + h.p([h.Class("text-muted-fg text-sm")], [description]), + ], + ) + +/** A failure under the form, announced to screen readers. */ +export const authError = (h: HtmlBuilder, message: string | null): Html | null => + message === null ? null : h.p([h.Class("mt-4 text-danger text-sm"), h.Role("alert")], [message]) + +export const signInLink = (h: HtmlBuilder, label: string): Html => + h.a( + [ + h.Class("text-fg text-sm underline underline-offset-2"), + h.Href(hrefOf(AppRoute.SignIn({ splat: "", redirectUrl: Option.none() }))), + ], + [label], + ) diff --git a/apps/web-foldkit/src/page/auth/register/command.ts b/apps/web-foldkit/src/page/auth/register/command.ts new file mode 100644 index 000000000..38ec330d3 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/register/command.ts @@ -0,0 +1,19 @@ +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { register } from "../../../auth/flows" +import { Message } from "./message" + +export const Register = Command.define("Register", { + args: { + flowId: Schema.String, + reference: Schema.String, + firstName: Schema.String, + lastName: Schema.String, + }, + messages: [Message.LeftForApp, Message.FailedRegistration], + execute: (input) => + register(input).pipe( + Effect.as(Message.LeftForApp()), + Effect.catch((error) => Effect.succeed(Message.FailedRegistration({ message: error.message }))), + ), +}) diff --git a/apps/web-foldkit/src/page/auth/register/index.ts b/apps/web-foldkit/src/page/auth/register/index.ts new file mode 100644 index 000000000..b77a69262 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/register/index.ts @@ -0,0 +1,12 @@ +import { definePage } from "../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/auth/register`: a verified provider identity with no Hazel account yet picks a name. */ +export const page = definePage( + "AuthRegister", + { Model, Message }, + { routes: ["AuthRegister"], init, update, view }, +) diff --git a/apps/web-foldkit/src/page/auth/register/message.ts b/apps/web-foldkit/src/page/auth/register/message.ts new file mode 100644 index 000000000..16948fb87 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/register/message.ts @@ -0,0 +1,12 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" + +export const Message = defineMessageUnion({ + ChangedFirstName: { value: Schema.String }, + ChangedLastName: { value: Schema.String }, + SubmittedRegistration: {}, + /** The account exists and the browser is loading the app. */ + LeftForApp: {}, + FailedRegistration: { message: Schema.NullOr(Schema.String) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/auth/register/model.ts b/apps/web-foldkit/src/page/auth/register/model.ts new file mode 100644 index 000000000..2fb8a616d --- /dev/null +++ b/apps/web-foldkit/src/page/auth/register/model.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect" + +export const Model = Schema.Struct({ + /** From the OAuth callback's redirect; either missing means the link is unusable. */ + flowId: Schema.NullOr(Schema.String), + reference: Schema.NullOr(Schema.String), + firstName: Schema.String, + lastName: Schema.String, + /** Errors show after the first submit, not while the visitor is still typing. */ + hasSubmitted: Schema.Boolean, + isSubmitting: Schema.Boolean, + error: Schema.NullOr(Schema.String), +}) +export type Model = typeof Model.Type + +export const isFirstNameMissing = (model: Model) => model.firstName.trim().length === 0 diff --git a/apps/web-foldkit/src/page/auth/register/update.ts b/apps/web-foldkit/src/page/auth/register/update.ts new file mode 100644 index 000000000..5e41e1be0 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/register/update.ts @@ -0,0 +1,50 @@ +import { Option } from "effect" +import { modifyFields } from "foldkit/struct" +import type { RouteOf } from "../../../route" +import type { PageReturn } from "../../contract" +import { Register } from "./command" +import { Message } from "./message" +import { isFirstNameMissing, type Model } from "./model" + +type Return = PageReturn + +export const init = (route: RouteOf<"AuthRegister">): Return => ({ + model: { + flowId: Option.getOrNull(route.flowId), + reference: Option.getOrNull(route.reference), + firstName: "", + lastName: "", + hasSubmitted: false, + isSubmitting: false, + error: null, + }, +}) + +const submit = (model: Model): Return => { + const submitted = modifyFields(model, { hasSubmitted: () => true, error: () => null }) + if (model.isSubmitting || isFirstNameMissing(model) || model.flowId === null || model.reference === null) + return { model: submitted } + return { + model: modifyFields(submitted, { isSubmitting: () => true }), + commands: [ + Register({ + flowId: model.flowId, + reference: model.reference, + firstName: model.firstName, + lastName: model.lastName, + }), + ], + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + ChangedFirstName: ({ value }) => ({ model: modifyFields(model, { firstName: () => value }) }), + ChangedLastName: ({ value }) => ({ model: modifyFields(model, { lastName: () => value }) }), + SubmittedRegistration: () => submit(model), + // Stays busy until the browser navigates away. + LeftForApp: () => ({ model }), + FailedRegistration: ({ message }) => ({ + model: modifyFields(model, { isSubmitting: () => false, error: () => message }), + }), + }) diff --git a/apps/web-foldkit/src/page/auth/register/view.ts b/apps/web-foldkit/src/page/auth/register/view.ts new file mode 100644 index 000000000..c3eabef85 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/register/view.ts @@ -0,0 +1,111 @@ +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../ui/button" +import { loader } from "../../../ui/loader" +import { textField } from "../../../ui/text-field" +import type { PageViewInputs } from "../../contract" +import { authError, authFrame, authHeading, signInLink } from "../layout" +import { Message } from "./message" +import { isFirstNameMissing, type Model } from "./model" + +const toFirstName = (value: string) => Message.ChangedFirstName({ value }) +const toLastName = (value: string) => Message.ChangedLastName({ value }) + +const nameField = ( + h: HtmlBuilder, + options: { + readonly id: string + readonly label: string + readonly value: string + readonly autocomplete: string + readonly isRequired: boolean + readonly error: string | null + readonly isAutoFocused: boolean + readonly onInput: (value: string) => Message + }, +): Html => + textField( + h, + { + id: options.id, + value: options.value, + isRequired: options.isRequired, + isInvalid: options.error !== null, + onInput: options.onInput, + }, + (parts) => [ + parts.label([options.label]), + parts.input({ + attributes: [ + h.Attribute("autocomplete", options.autocomplete), + ...(options.isAutoFocused ? [h.Autofocus(true)] : []), + ], + }), + parts.fieldError([options.error ?? ""]), + ], + ) + +const form = (h: HtmlBuilder, model: Model): Html => + h.form( + [h.Class("flex flex-col gap-4"), h.OnSubmit(Message.SubmittedRegistration())], + [ + nameField(h, { + id: "register-first-name", + label: "First name", + value: model.firstName, + autocomplete: "given-name", + isRequired: true, + error: model.hasSubmitted && isFirstNameMissing(model) ? "Enter your first name" : null, + isAutoFocused: true, + onInput: toFirstName, + }), + nameField(h, { + id: "register-last-name", + label: "Last name", + value: model.lastName, + autocomplete: "family-name", + isRequired: false, + error: null, + isAutoFocused: false, + onInput: toLastName, + }), + button( + h, + { + intent: "primary", + className: "mt-2 w-full", + isDisabled: model.isSubmitting, + onPress: Message.SubmittedRegistration(), + }, + model.isSubmitting + ? [loader(h, { className: "size-4" }), "Creating your account..."] + : ["Continue"], + ), + ], + ) + +const brokenLink = (h: HtmlBuilder): ReadonlyArray => [ + authHeading( + h, + "This sign-up link doesn't work", + "It's missing details from your sign-in. Start again to continue.", + ), + signInLink(h, "Back to sign in"), +] + +export const view = Submodel.defineView((model, { shared }, h) => + authFrame( + h, + shared.nowMs, + model.flowId === null || model.reference === null + ? brokenLink(h) + : [ + authHeading(h, "Welcome to Hazel", "What should your teammates call you?"), + form(h, model), + authError(h, model.error), + ...(model.error === null + ? [] + : [h.p([h.Class("mt-2")], [signInLink(h, "Start sign-in again")])]), + ], + ), +) diff --git a/apps/web-foldkit/src/page/auth/scene.test.ts b/apps/web-foldkit/src/page/auth/scene.test.ts index da4841109..e273a2e88 100644 --- a/apps/web-foldkit/src/page/auth/scene.test.ts +++ b/apps/web-foldkit/src/page/auth/scene.test.ts @@ -1,68 +1,113 @@ // @vitest-environment jsdom import { Option } from "effect" import * as Scene from "foldkit/scene" -import { describe, expect, test } from "vitest" -import { makeShared, pageScene } from "../../test/pages-fixtures" +import { describe, test } from "vitest" import { AppRoute } from "../../route" -import { ClerkMountMessage, MountClerkComponent } from "./clerk-mount" -import { init, signInView, signUpView, update } from "./index" +import { makeShared, pageScene } from "../../test/pages-fixtures" +import { Register } from "./register/command" +import { Message as RegisterMessage } from "./register/message" +import { init as initRegister, update as updateRegister } from "./register/update" +import { view as registerView } from "./register/view" +import { StartOAuthSignIn, StartPasskeySignIn } from "./sign-in/command" +import { Message as SignInMessage } from "./sign-in/message" +import { init as initSignIn, update as updateSignIn } from "./sign-in/update" +import { view as signInView } from "./sign-in/view" -/** Sign-in and sign-up: Clerk's form mounts with the route's redirect, and its outcome changes nothing here. */ +/** Sign-in starts OAuth or a passkey with the route's redirect; registration submits a name. */ const signedOut = makeShared({ auth: "SignedOut", currentUser: null, organization: null, member: null }) -const pendingMountArgs = (expected: Record) => - Scene.tap(({ mounts }) => expect(mounts.map((mount) => mount.args)).toEqual([expected])) +const github = Scene.role("button", { name: "Continue with GitHub" }) +const passkey = Scene.role("button", { name: "Sign in with a passkey" }) + +const signIn = (redirectUrl: Option.Option) => + initSignIn(AppRoute.SignIn.make({ splat: "", redirectUrl })).model describe("sign in", () => { - test("mounts Clerk's SignIn on its container, sending both redirects to ?redirect_url", () => { - const route = AppRoute.SignIn.make({ splat: "", redirectUrl: Option.some("/join/hazel") }) + test("a provider starts OAuth with the route's redirect, and disables the other choices", () => { + Scene.scene( + pageScene(updateSignIn, signInView, signedOut), + Scene.given(signIn(Option.some("/join/hazel"))), + Scene.expect(Scene.role("heading", { name: "Sign in to Hazel" })).toExist(), + Scene.click(github), + Scene.Command.expectExact(StartOAuthSignIn({ provider: "github", returnTo: "/join/hazel" })), + Scene.expect(passkey).toHaveAttr("data-disabled", "true"), + Scene.Command.resolve(StartOAuthSignIn, SignInMessage.LeftForSignIn()), + ) + }) + + test("an off-site redirect is dropped", () => { + Scene.scene( + pageScene(updateSignIn, signInView, signedOut), + Scene.given(signIn(Option.some("//evil.example/x"))), + Scene.click(github), + Scene.Command.expectExact(StartOAuthSignIn({ provider: "github", returnTo: null })), + Scene.Command.resolve(StartOAuthSignIn, SignInMessage.LeftForSignIn()), + ) + }) + + test("a failed passkey sign-in shows why and lets the visitor try again; a cancelled one stays quiet", () => { Scene.scene( - pageScene(update, signInView, signedOut), - Scene.given(init(route).model), - Scene.expect(Scene.selector('[data-clerk-component="SignIn"]')).toExist(), - pendingMountArgs({ - component: "SignIn", - props: { - routing: "path", - path: "/sign-in", - signUpUrl: "/sign-up", - fallbackRedirectUrl: "/join/hazel", - signUpFallbackRedirectUrl: "/join/hazel", - }, - }), - Scene.Mount.resolve(MountClerkComponent, ClerkMountMessage.MountedClerkComponent({ component: "SignIn" })), - Scene.expect(Scene.selector('[data-clerk-component="SignIn"]')).toExist(), + pageScene(updateSignIn, signInView, signedOut), + Scene.given(signIn(Option.none())), + Scene.click(passkey), + Scene.Command.expectExact(StartPasskeySignIn({ returnTo: null })), + Scene.Command.resolve( + StartPasskeySignIn, + SignInMessage.FailedSignIn({ message: "Sign-in failed." }), + ), + Scene.expect(Scene.role("alert")).toHaveText("Sign-in failed."), + Scene.click(passkey), + Scene.Command.resolve(StartPasskeySignIn, SignInMessage.FailedSignIn({ message: null })), + Scene.expect(Scene.role("alert")).toBeAbsent(), ) }) - test("without a redirect it returns to the app root", () => { - const route = AppRoute.SignIn.make({ splat: "factor-one", redirectUrl: Option.none() }) + test("/sign-up offers the same choices and links back to sign-in", () => { Scene.scene( - pageScene(update, signInView, signedOut), - Scene.given(init(route).model), - Scene.tap(({ mounts }) => expect(mounts[0]?.args).toMatchObject({ props: { fallbackRedirectUrl: "/" } })), - Scene.Mount.resolve(MountClerkComponent, ClerkMountMessage.FailedMountClerkComponent({ component: "SignIn" })), + pageScene(updateSignIn, signInView, signedOut), + Scene.given(initSignIn(AppRoute.SignUp.make({ splat: "", redirectUrl: Option.none() })).model), + Scene.expect(Scene.role("heading", { name: "Create your Hazel account" })).toExist(), + Scene.expect(Scene.role("link", { name: "Sign in" })).toHaveAttr("href", "/sign-in"), ) }) }) -describe("sign up", () => { - test("mounts Clerk's SignUp with the sign-in link and the redirect", () => { - const route = AppRoute.SignUp.make({ splat: "", redirectUrl: Option.some("/hazel") }) +describe("register", () => { + const route = AppRoute.AuthRegister.make({ flowId: Option.some("f1"), reference: Option.some("r1") }) + + test("submits the name with the callback's flow and reference", () => { + Scene.scene( + pageScene(updateRegister, registerView, signedOut), + Scene.given(initRegister(route).model), + Scene.type(Scene.label("First name"), "Ada"), + Scene.type(Scene.label("Last name"), "Lovelace"), + Scene.click(Scene.role("button", { name: "Continue" })), + Scene.Command.expectExact( + Register({ flowId: "f1", reference: "r1", firstName: "Ada", lastName: "Lovelace" }), + ), + Scene.Command.resolve(Register, RegisterMessage.LeftForApp()), + ) + }) + + test("a missing first name blocks the submit", () => { + Scene.scene( + pageScene(updateRegister, registerView, signedOut), + Scene.given(initRegister(route).model), + Scene.click(Scene.role("button", { name: "Continue" })), + Scene.Command.expectNone(), + Scene.expect(Scene.text("Enter your first name")).toExist(), + ) + }) + + test("a link without its flow says so and points back to sign-in", () => { Scene.scene( - pageScene(update, signUpView, signedOut), - Scene.given(init(route).model), - pendingMountArgs({ - component: "SignUp", - props: { - routing: "path", - path: "/sign-up", - signInUrl: "/sign-in", - fallbackRedirectUrl: "/hazel", - signInFallbackRedirectUrl: "/hazel", - }, - }), - Scene.Mount.resolve(MountClerkComponent, ClerkMountMessage.MountedClerkComponent({ component: "SignUp" })), + pageScene(updateRegister, registerView, signedOut), + Scene.given( + initRegister(AppRoute.AuthRegister.make({ flowId: Option.none(), reference: Option.none() })) + .model, + ), + Scene.expect(Scene.role("heading", { name: "This sign-up link doesn't work" })).toExist(), + Scene.expect(Scene.role("link", { name: "Back to sign in" })).toHaveAttr("href", "/sign-in"), ) }) }) diff --git a/apps/web-foldkit/src/page/auth/sign-in/command.ts b/apps/web-foldkit/src/page/auth/sign-in/command.ts new file mode 100644 index 000000000..584f0b200 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/sign-in/command.ts @@ -0,0 +1,23 @@ +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { Provider } from "../../../auth/client" +import { signInWithPasskey, startOAuthSignIn } from "../../../auth/flows" +import { Message } from "./message" + +const settle = (flow: Effect.Effect) => + flow.pipe( + Effect.as(Message.LeftForSignIn()), + Effect.catch((error) => Effect.succeed(Message.FailedSignIn({ message: error.message }))), + ) + +export const StartOAuthSignIn = Command.define("StartOAuthSignIn", { + args: { provider: Schema.Literals(Provider), returnTo: Schema.NullOr(Schema.String) }, + messages: [Message.LeftForSignIn, Message.FailedSignIn], + execute: ({ provider, returnTo }) => settle(startOAuthSignIn(provider, returnTo)), +}) + +export const StartPasskeySignIn = Command.define("StartPasskeySignIn", { + args: { returnTo: Schema.NullOr(Schema.String) }, + messages: [Message.LeftForSignIn, Message.FailedSignIn], + execute: ({ returnTo }) => settle(signInWithPasskey(returnTo)), +}) diff --git a/apps/web-foldkit/src/page/auth/sign-in/index.ts b/apps/web-foldkit/src/page/auth/sign-in/index.ts new file mode 100644 index 000000000..ebe38fb49 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/sign-in/index.ts @@ -0,0 +1,12 @@ +import { definePage } from "../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/sign-in` and `/sign-up`: GitHub, Google or a passkey. One instance across old Clerk sub-paths. */ +export const page = definePage( + "SignIn", + { Model, Message }, + { routes: ["SignIn", "SignUp"], key: (route) => route._tag, init, update, view }, +) diff --git a/apps/web-foldkit/src/page/auth/sign-in/message.ts b/apps/web-foldkit/src/page/auth/sign-in/message.ts new file mode 100644 index 000000000..9b49a05d8 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/sign-in/message.ts @@ -0,0 +1,13 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { Provider } from "../../../auth/client" + +export const Message = defineMessageUnion({ + ClickedProvider: { provider: Schema.Literals(Provider) }, + ClickedPasskey: {}, + /** The browser is leaving for the provider or the app; nothing to render. */ + LeftForSignIn: {}, + /** `message` is `null` when the visitor cancelled the passkey prompt. */ + FailedSignIn: { message: Schema.NullOr(Schema.String) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/auth/sign-in/model.ts b/apps/web-foldkit/src/page/auth/sign-in/model.ts new file mode 100644 index 000000000..54c1700ad --- /dev/null +++ b/apps/web-foldkit/src/page/auth/sign-in/model.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect" +import { Provider } from "../../../auth/client" + +/** What the visitor started: a provider's OAuth redirect or the passkey prompt. */ +export const Pending = Schema.NullOr(Schema.Union([Schema.Literals(Provider), Schema.Literal("passkey")])) +export type Pending = typeof Pending.Type + +export const Model = Schema.Struct({ + /** `/sign-up` shows the same choices: the first OAuth sign-in creates the account. */ + mode: Schema.Literals(["SignIn", "SignUp"]), + /** `?redirect_url=`: where to go once signed in. */ + redirectUrl: Schema.NullOr(Schema.String), + pending: Pending, + error: Schema.NullOr(Schema.String), +}) +export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/auth/sign-in/update.ts b/apps/web-foldkit/src/page/auth/sign-in/update.ts new file mode 100644 index 000000000..ddefa06d3 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/sign-in/update.ts @@ -0,0 +1,42 @@ +import { Option } from "effect" +import { modifyFields } from "foldkit/struct" +import { safeReturnTo } from "../../../auth/return-to" +import type { RouteOf } from "../../../route" +import type { PageReturn } from "../../contract" +import { StartOAuthSignIn, StartPasskeySignIn } from "./command" +import { Message } from "./message" +import type { Model } from "./model" + +type Return = PageReturn + +export const init = (route: RouteOf<"SignIn" | "SignUp">): Return => ({ + model: { + mode: route._tag, + redirectUrl: safeReturnTo(Option.getOrNull(route.redirectUrl)), + pending: null, + error: null, + }, +}) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + ClickedProvider: ({ provider }) => + model.pending !== null + ? { model } + : { + model: modifyFields(model, { pending: () => provider, error: () => null }), + commands: [StartOAuthSignIn({ provider, returnTo: model.redirectUrl })], + }, + ClickedPasskey: () => + model.pending !== null + ? { model } + : { + model: modifyFields(model, { pending: () => "passkey" as const, error: () => null }), + commands: [StartPasskeySignIn({ returnTo: model.redirectUrl })], + }, + // The page stays busy until the browser navigates away. + LeftForSignIn: () => ({ model }), + FailedSignIn: ({ message }) => ({ + model: modifyFields(model, { pending: () => null, error: () => message }), + }), + }) diff --git a/apps/web-foldkit/src/page/auth/sign-in/view.ts b/apps/web-foldkit/src/page/auth/sign-in/view.ts new file mode 100644 index 000000000..219027825 --- /dev/null +++ b/apps/web-foldkit/src/page/auth/sign-in/view.ts @@ -0,0 +1,110 @@ +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Provider } from "../../../auth/client" +import { IconLock } from "../../../icons" +import { AppRoute, hrefOf } from "../../../route" +import { button } from "../../../ui/button" +import { loader } from "../../../ui/loader" +import type { PageViewInputs } from "../../contract" +import { authError, authFrame, authHeading } from "../layout" +import { Message } from "./message" +import type { Model, Pending } from "./model" + +const providers: ReadonlyArray<{ readonly id: Provider; readonly label: string; readonly domain: string }> = [ + { id: "github", label: "GitHub", domain: "github.com" }, + { id: "google", label: "Google", domain: "google.com" }, +] + +const brandIcon = (h: HtmlBuilder, domain: string, theme: "light" | "dark"): Html => + h.img([ + h.Attribute("src", `https://cdn.brandfetch.io/${domain}/w/64/h/64/theme/${theme}/icon`), + h.Attribute("alt", ""), + h.Class("size-4"), + ]) + +const choice = ( + h: HtmlBuilder, + options: { + readonly label: string + readonly icon: Html + readonly isPending: boolean + readonly isDisabled: boolean + readonly onPress: Message + }, +): Html => + button( + h, + { + intent: "outline", + className: "w-full", + isDisabled: options.isDisabled, + onPress: options.onPress, + }, + [options.isPending ? loader(h, { className: "size-4" }) : options.icon, options.label], + ) + +const divider = (h: HtmlBuilder): Html => + h.div( + [h.Class("flex items-center gap-3 text-muted-fg text-xs")], + [h.div([h.Class("h-px flex-1 bg-border")]), "or", h.div([h.Class("h-px flex-1 bg-border")])], + ) + +const choices = (h: HtmlBuilder, pending: Pending, theme: "light" | "dark"): Html => + h.div( + [h.Class("flex flex-col gap-3")], + [ + ...providers.map(({ id, label, domain }) => + choice(h, { + label: `Continue with ${label}`, + icon: brandIcon(h, domain, theme), + isPending: pending === id, + isDisabled: pending !== null, + onPress: Message.ClickedProvider({ provider: id }), + }), + ), + divider(h), + choice(h, { + label: "Sign in with a passkey", + icon: IconLock(h, { className: "size-4", attributes: { "aria-hidden": "true" } }), + isPending: pending === "passkey", + isDisabled: pending !== null, + onPress: Message.ClickedPasskey(), + }), + ], + ) + +const switchLink = (h: HtmlBuilder, model: Model): Html => { + const isSignIn = model.mode === "SignIn" + const target = isSignIn ? AppRoute.SignUp : AppRoute.SignIn + return h.p( + [h.Class("mt-8 text-muted-fg text-sm")], + [ + isSignIn ? "New to Hazel? " : "Already have an account? ", + h.a( + [ + h.Class("text-fg underline underline-offset-2"), + h.Href( + hrefOf(target({ splat: "", redirectUrl: Option.fromNullishOr(model.redirectUrl) })), + ), + ], + [isSignIn ? "Create an account" : "Sign in"], + ), + ], + ) +} + +export const view = Submodel.defineView((model, { shared }, h) => + authFrame(h, shared.nowMs, [ + model.mode === "SignIn" + ? authHeading(h, "Sign in to Hazel", "Welcome back. Pick how you'd like to sign in.") + : authHeading( + h, + "Create your Hazel account", + "Sign up with GitHub or Google. It takes a few seconds.", + ), + choices(h, model.pending, shared.theme.resolved), + authError(h, model.error), + switchLink(h, model), + ]), +) diff --git a/apps/web-foldkit/src/page/join/command.ts b/apps/web-foldkit/src/page/join/command.ts index b7ac1d055..39dbb1836 100644 --- a/apps/web-foldkit/src/page/join/command.ts +++ b/apps/web-foldkit/src/page/join/command.ts @@ -53,7 +53,7 @@ export const JoinWorkspace = Command.define("JoinWorkspace", { }), }) -/** `login({ returnTo })`: a full page load of Clerk's sign-in, coming back here afterwards. */ +/** `login({ returnTo })`: a full page load of sign-in, coming back here afterwards. */ export const RedirectToSignIn = Command.define("RedirectToSignIn", { args: { returnTo: Schema.String }, messages: [Message.CompletedRedirectToSignIn], diff --git a/apps/web-foldkit/src/page/join/view.ts b/apps/web-foldkit/src/page/join/view.ts index a13601336..6aa4682dc 100644 --- a/apps/web-foldkit/src/page/join/view.ts +++ b/apps/web-foldkit/src/page/join/view.ts @@ -220,7 +220,7 @@ const inviteView = (h: HtmlBuilder, model: Model, org: PublicOrganizati ), ) -/** `useAuth().isLoading`: Clerk not loaded yet, or signed in while `user.me` is pending. */ +/** `useAuth().isLoading`: the session check is pending, or signed in while `user.me` is pending. */ const isAuthLoading = (shared: Shared) => shared.auth === "Loading" || (shared.auth === "SignedIn" && shared.currentUser === null) diff --git a/apps/web-foldkit/src/page/registry.ts b/apps/web-foldkit/src/page/registry.ts index 812ae38b4..477939499 100644 --- a/apps/web-foldkit/src/page/registry.ts +++ b/apps/web-foldkit/src/page/registry.ts @@ -63,7 +63,8 @@ export const pages = [ SettingsInvitations.page, SettingsCustomEmojis.page, Auth.signInPage, - Auth.signUpPage, + Auth.registerPage, + Auth.continuePage, Onboarding.page, OnboardingSetupOrganization.page, SelectOrganization.page, diff --git a/apps/web-foldkit/src/page/root/index.ts b/apps/web-foldkit/src/page/root/index.ts index 209beebf1..5ab14e663 100644 --- a/apps/web-foldkit/src/page/root/index.ts +++ b/apps/web-foldkit/src/page/root/index.ts @@ -1,11 +1,13 @@ import { OrganizationId, UserId } from "@hazel/schema" import { and, eq } from "@tanstack/db" -import { Option, Schema, Stream } from "effect" -import { Submodel, Subscription } from "foldkit" +import { Effect, Option, Schema, Stream } from "effect" +import { Command, Submodel, Subscription } from "foldkit" import { modifyFields } from "foldkit/struct" import { organizationCollection, organizationMemberCollection } from "~/db/collections" +import { takeReturnTo } from "../../auth/return-to" import { liveQueryStream } from "../../data/live-query" import { rootRedirect } from "../../redirect" +import type { CurrentUser } from "../../session" import { appLoader } from "../../shell/layouts" import { definePage, @@ -21,14 +23,28 @@ import { Message, Model } from "./model" type Return = PageReturn -/** Redirects once both the user and their membership are known. */ +export const TakeReturnTo = Command.define("TakeReturnTo", { + messages: [Message.TookReturnTo], + execute: takeReturnTo.pipe(Effect.map((returnTo) => Message.TookReturnTo({ returnTo }))), +}) + +/** Where to go: back to where sign-in started, unless onboarding comes first; else `rootRedirect`. */ +const targetOf = (model: Model, user: CurrentUser): string => + model.returnTo && user.isOnboarded + ? model.returnTo + : rootRedirect(user, Option.fromNullishOr(model.membership)) + +/** Redirects once the user, their membership and the remembered page are known. */ const redirectWhenReady = (model: Model, shared: Shared): Return => - model.hasRedirected || model.membership === undefined || shared.currentUser === null + model.hasRedirected || + model.membership === undefined || + model.returnTo === undefined || + shared.currentUser === null ? { model } : { model: modifyFields(model, { hasRedirected: () => true }), outMessage: PageOutMessage.RequestedNavigation({ - href: rootRedirect(shared.currentUser, Option.fromNullishOr(model.membership)), + href: targetOf(model, shared.currentUser), replace: true, }), } @@ -78,17 +94,26 @@ const subscriptions = Subscription.make, Message>() ), })) +export const init = (): Return => ({ + model: { membership: undefined, returnTo: undefined, hasRedirected: false }, + commands: [TakeReturnTo()], +}) + +export const update = (model: Model, message: Message, shared: Shared): Return => + Message.match(message, { + UpdatedMembership: ({ membership }) => + redirectWhenReady(modifyFields(model, { membership: () => membership }), shared), + TookReturnTo: ({ returnTo }) => + redirectWhenReady(modifyFields(model, { returnTo: () => returnTo }), shared), + }) + export const page = definePage( "Root", { Model, Message }, { routes: ["Root"], - init: () => ({ model: { membership: undefined, hasRedirected: false } }), - update: (model, message, shared) => - Message.match(message, { - UpdatedMembership: ({ membership }) => - redirectWhenReady(modifyFields(model, { membership: () => membership }), shared), - }), + init, + update, view: Submodel.defineView((_model, _inputs, h) => appLoader(h)), subscriptions, sharedChanged: redirectWhenReady, diff --git a/apps/web-foldkit/src/page/root/model.ts b/apps/web-foldkit/src/page/root/model.ts index 4faee3a73..55949831e 100644 --- a/apps/web-foldkit/src/page/root/model.ts +++ b/apps/web-foldkit/src/page/root/model.ts @@ -10,11 +10,14 @@ export const RootMembership = Schema.Struct({ export const Model = Schema.Struct({ /** `undefined` until the live query is ready; `null` when the user has no membership. */ membership: Schema.UndefinedOr(Schema.NullOr(RootMembership)), + /** The page sign-in started from (`auth/return-to.ts`); `undefined` until read, `null` if none. */ + returnTo: Schema.UndefinedOr(Schema.NullOr(Schema.String)), hasRedirected: Schema.Boolean, }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ UpdatedMembership: { membership: Schema.NullOr(RootMembership) }, + TookReturnTo: { returnTo: Schema.NullOr(Schema.String) }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/root/story.test.ts b/apps/web-foldkit/src/page/root/story.test.ts new file mode 100644 index 000000000..dfc766a26 --- /dev/null +++ b/apps/web-foldkit/src/page/root/story.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { Command, expectOutMessage, given, message, story } from "foldkit/story" +import { describe, expect, test } from "vitest" +import { currentUser, makeShared, organizationId, storyUpdate } from "../../test/pages-fixtures" +import { PageOutMessage } from "../out-message" +import { init, TakeReturnTo, update } from "./index" +import { Message } from "./model" + +/** `/` forwards once the user, their membership and the page sign-in started from are known. */ + +const membership = { organizationId, slug: "hazel" } +const signedIn = storyUpdate(update, makeShared()) +const notOnboarded = storyUpdate(update, makeShared({ currentUser: { ...currentUser, isOnboarded: false } })) +const go = (href: string) => expectOutMessage(PageOutMessage.RequestedNavigation({ href, replace: true })) + +describe("root redirect", () => { + test("reads the remembered page first", () => { + expect(init().commands?.map((command) => command.name)).toEqual([TakeReturnTo.name]) + }) + + test("without a remembered page it goes to the user's organization", () => { + story( + signedIn, + given(init().model), + message(Message.UpdatedMembership({ membership })), + Command.expectNone(), + message(Message.TookReturnTo({ returnTo: null })), + go("/hazel"), + ) + }) + + test("back to the page sign-in started from", () => { + story( + signedIn, + given(init().model), + message(Message.TookReturnTo({ returnTo: "/join/acme" })), + message(Message.UpdatedMembership({ membership: null })), + go("/join/acme"), + ) + }) + + test("onboarding comes before the remembered page", () => { + story( + notOnboarded, + given(init().model), + message(Message.TookReturnTo({ returnTo: "/join/acme" })), + message(Message.UpdatedMembership({ membership: null })), + go("/onboarding"), + ) + }) +}) diff --git a/apps/web-foldkit/src/route.test.ts b/apps/web-foldkit/src/route.test.ts index 73bc3dba5..e268efd3a 100644 --- a/apps/web-foldkit/src/route.test.ts +++ b/apps/web-foldkit/src/route.test.ts @@ -87,7 +87,10 @@ describe("urlToAppRoute", () => { splat: "factor-one", redirectUrl: Option.some("/hazel"), }) - expect(parse(`/onboarding?orgId=${orgId}`)).toMatchObject({ orgId: Option.some(orgId), step: Option.none() }) + expect(parse(`/onboarding?orgId=${orgId}`)).toMatchObject({ + orgId: Option.some(orgId), + step: Option.none(), + }) }) it("types the search params legacy reads with `Route.useSearch()`", () => { @@ -96,7 +99,9 @@ describe("urlToAppRoute", () => { step: Option.some("themeSelection"), }) expect( - parse("/hazel/my-settings/linked-accounts?connection_status=error&provider=discord&error_code=db_error"), + parse( + "/hazel/my-settings/linked-accounts?connection_status=error&provider=discord&error_code=db_error", + ), ).toMatchObject({ _tag: "MySettingsLinkedAccounts", connectionStatus: Option.some("error"), @@ -136,7 +141,9 @@ describe("urlToAppRoute", () => { describe("redirects", () => { it("leaves the channel settings index to its redirect page", () => { - expect(routeRedirect(parse(`/hazel/channels/${channelId}/settings`), { isProd: true })).toEqual(Option.none()) + expect(routeRedirect(parse(`/hazel/channels/${channelId}/settings`), { isProd: true })).toEqual( + Option.none(), + ) expect(routeRedirect(parse("/hazel/settings/team"), { isProd: true })).toEqual(Option.none()) }) @@ -204,6 +211,17 @@ describe("hrefOf", () => { [AppRoute.SignUp({ splat: "", redirectUrl: none }), "/sign-up"], [AppRoute.SignUp({ splat: "verify", redirectUrl: none }), "/sign-up/verify"], ], + AuthRegister: [ + [AppRoute.AuthRegister({ flowId: none, reference: none }), "/auth/register"], + [ + AppRoute.AuthRegister({ flowId: Option.some("f1"), reference: Option.some("r1") }), + "/auth/register?flowId=f1&reference=r1", + ], + ], + AuthContinue: [ + [AppRoute.AuthContinue({ provider: none }), "/auth/continue"], + [AppRoute.AuthContinue({ provider: Option.some("github") }), "/auth/continue?provider=github"], + ], Join: [[AppRoute.Join({ slug: "hazel" }), "/join/hazel"]], Onboarding: [ [AppRoute.Onboarding({ orgId: Option.none(), step: none }), "/onboarding"], diff --git a/apps/web-foldkit/src/route.ts b/apps/web-foldkit/src/route.ts index c6958f8d4..fe4a25bc6 100644 --- a/apps/web-foldkit/src/route.ts +++ b/apps/web-foldkit/src/route.ts @@ -8,6 +8,10 @@ export const AppRoute = defineRouteUnion({ Root: {}, SignIn: { splat: Schema.String, redirectUrl: Schema.Option(Schema.String) }, SignUp: { splat: Schema.String, redirectUrl: Schema.Option(Schema.String) }, + /** Where the OAuth callback sends a first-time user to pick their name (`?flowId=&reference=`). */ + AuthRegister: { flowId: Schema.Option(Schema.String), reference: Schema.Option(Schema.String) }, + /** Where the OAuth callback sends a user whose provider account it just linked (`?provider=`). */ + AuthContinue: { provider: Schema.Option(Schema.String) }, Join: { slug: Schema.String }, /** `?orgId=&step=` (`validateSearch` in `onboarding/index.tsx`). */ Onboarding: { orgId: Schema.Option(OrganizationId), step: Schema.Option(Schema.String) }, @@ -68,6 +72,11 @@ export type RouteOf = Extract ({ bare: pipe( @@ -123,6 +132,18 @@ const signUp = authRouters("sign-up", AppRoute.SignUp) /** One router per route tag, used to parse URLs and to build them back (`hrefOf`). */ const routers = { Root: pipe(Route.root, Route.mapTo(AppRoute.Root)), + AuthRegister: pipe( + literal("auth"), + slash(literal("register")), + query(RegisterQuery), + Route.mapTo(AppRoute.AuthRegister), + ), + AuthContinue: pipe( + literal("auth"), + slash(literal("continue")), + query(ContinueQuery), + Route.mapTo(AppRoute.AuthContinue), + ), Join: pipe(literal("join"), slash(string("slug")), Route.mapTo(AppRoute.Join)), Onboarding: pipe(literal("onboarding"), query(OnboardingQuery), Route.mapTo(AppRoute.Onboarding)), OnboardingSetupOrganization: pipe( @@ -222,6 +243,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( signIn.splat, signUp.bare, signUp.splat, + routers.AuthRegister, + routers.AuthContinue, routers.Join, routers.Onboarding, routers.OnboardingSetupOrganization, @@ -278,7 +301,14 @@ export const orgSlugOf = (route: AppRoute): string | undefined => "orgSlug" in route ? route.orgSlug : undefined /** Routes outside `/_app`, which legacy renders without the signed-in gate. */ -export const isPublicRoute = AppRoute.isAnyOf(["SignIn", "SignUp", "Join", "NotFound"]) +export const isPublicRoute = AppRoute.isAnyOf([ + "SignIn", + "SignUp", + "AuthRegister", + "AuthContinue", + "Join", + "NotFound", +]) /** Which secondary sidebar and section layout an org route gets (legacy `AppSidebar`). */ export type OrgSection = "Chat" | "Settings" | "MySettings" | "Notifications" diff --git a/apps/web-foldkit/src/rpc.ts b/apps/web-foldkit/src/rpc.ts index 340e1abef..a7f873a12 100644 --- a/apps/web-foldkit/src/rpc.ts +++ b/apps/web-foldkit/src/rpc.ts @@ -26,8 +26,9 @@ import { UserPresenceStatusRpcs, UserRpcs, } from "@hazel/domain/rpc" -import { AuthMiddlewareClientLive } from "~/lib/rpc-auth-middleware" +import { AuthMiddlewareClientLive, RpcCredentialsLive } from "~/lib/rpc-auth-middleware" import { CustomFetchLive } from "~/lib/services/common/api-client" +import { HazelAuthClient, HazelAuthClientLive } from "./auth/client" import { MessageWindows, MessageWindowsLive } from "./data/message-windows" /** Same groups, transport and auth middleware as the legacy `HazelRpcClient`, as a plain Effect service. */ @@ -58,7 +59,7 @@ const makeClient = RpcClient.make(AllRpcs, { flatten: true }) export class HazelRpc extends Context.Service>()("HazelRpc") {} const ProtocolLive = RpcClient.layerProtocolHttp({ url: `${import.meta.env.VITE_BACKEND_URL}/rpc` }).pipe( - Layer.provide(FetchHttpClient.layer), + Layer.provide(FetchHttpClient.layer.pipe(Layer.provide(RpcCredentialsLive))), Layer.provide(RpcSerialization.layerNdjson), ) @@ -75,9 +76,16 @@ export class HazelApiClient extends Context.Service( ) } -/** `sign-in/$.tsx` and `sign-up/$.tsx`: Clerk's prebuilt form, centered. */ -export const authLayout = (h: HtmlBuilder, page: Html): Html => - h.div([h.Class("flex min-h-dvh items-center justify-center p-6")], [page]) - /** `components/loader.tsx`: the full-page loader `_app` shows while auth or the user loads. */ export const appLoader = (h: HtmlBuilder): Html => h.div( diff --git a/apps/web-foldkit/src/vite-env.d.ts b/apps/web-foldkit/src/vite-env.d.ts index bdd082b37..e10f283f7 100644 --- a/apps/web-foldkit/src/vite-env.d.ts +++ b/apps/web-foldkit/src/vite-env.d.ts @@ -3,5 +3,12 @@ declare const __APP_VERSION__: string interface ImportMetaEnv { readonly VITE_BACKEND_URL: string readonly VITE_ELECTRIC_URL: string - readonly VITE_CLERK_PUBLISHABLE_KEY?: string +} + +/** + * clerk-js, still referenced by organization creation, invitations and profile editing until + * those move to Hazel's own RPCs. Nothing loads it any more, so those paths find it absent. + */ +interface Window { + Clerk?: { readonly loaded?: boolean } } diff --git a/apps/web/src/lib/access-token.ts b/apps/web/src/lib/access-token.ts new file mode 100644 index 000000000..ede9d9eb9 --- /dev/null +++ b/apps/web/src/lib/access-token.ts @@ -0,0 +1,61 @@ +/** + * Short-lived access token for the hosts that cannot see the API's session cookie + * (the Electric proxy and the Rivet actors). The backend exchanges the HttpOnly session + * cookie for an ES256 JWT at `POST /auth/token`; those hosts verify it against the API's + * JWKS. Requests to the API itself authenticate with the cookie and need no token. + */ + +interface CachedToken { + readonly token: string + readonly expiresAtMs: number +} + +/** Renew this long before expiry, so a token is never sent seconds from expiring. */ +const RENEW_BEFORE_MS = 60_000 + +let cached: CachedToken | null = null +let pending: Promise | null = null + +const backendUrl = () => import.meta.env.VITE_BACKEND_URL || "http://localhost:3003" + +const isTokenResponse = (value: unknown): value is { token: string; expiresIn: number } => + typeof value === "object" && + value !== null && + "token" in value && + typeof value.token === "string" && + "expiresIn" in value && + typeof value.expiresIn === "number" + +const exchange = async (): Promise => { + try { + const response = await fetch(`${backendUrl()}/auth/token`, { + method: "POST", + credentials: "include", + }) + if (!response.ok) { + cached = null + return null + } + const body: unknown = await response.json() + if (!isTokenResponse(body)) return null + cached = { token: body.token, expiresAtMs: Date.now() + body.expiresIn * 1000 } + return body.token + } catch { + return null + } +} + +/** The current access token, exchanged again shortly before it expires; `null` when signed out. */ +export const getAccessToken = async (): Promise => { + if (typeof window === "undefined") return null + if (cached !== null && cached.expiresAtMs - Date.now() > RENEW_BEFORE_MS) return cached.token + pending ??= exchange().finally(() => { + pending = null + }) + return pending +} + +/** Drops the cached token, for sign-out. */ +export const clearAccessToken = () => { + cached = null +} diff --git a/apps/web/src/lib/auth-fetch.ts b/apps/web/src/lib/auth-fetch.ts index d8c65e544..ccee5b5ee 100644 --- a/apps/web/src/lib/auth-fetch.ts +++ b/apps/web/src/lib/auth-fetch.ts @@ -1,16 +1,7 @@ /** - * Authenticated fetch. Attaches the Clerk bearer token and returns the - * response as-is. Auth state (signed-in vs. signed-out) is handled by the - * React tree via `` / ``, not here. + * Authenticated fetch for the Hazel API. The session is an HttpOnly cookie on the API + * host, so the request only has to carry credentials; the backend checks the Origin. */ -import { getClerkToken } from "./clerk-token" - -export const authenticatedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const token = await getClerkToken() - if (!token) return new Response(null, { status: 401 }) - return fetch(input, { - ...init, - headers: { ...init?.headers, Authorization: `Bearer ${token}` }, - }) -} +export const authenticatedFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => + fetch(input, { ...init, credentials: "include" }) diff --git a/apps/web/src/lib/clerk-token.ts b/apps/web/src/lib/clerk-token.ts deleted file mode 100644 index f5bf30b4b..000000000 --- a/apps/web/src/lib/clerk-token.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Clerk bearer-token helper for non-React callers (Electric fetch, RPC - * middleware). Out-of-the-box Clerk: poll `window.Clerk` until it's loaded, - * then call `session.getToken()`. - */ - -interface ClerkLike { - loaded?: boolean - session?: { getToken: () => Promise } | null - redirectToSignIn?: (options: { redirectUrl: string }) => Promise -} - -declare global { - interface Window { - Clerk?: ClerkLike - } -} - -export const getClerkToken = async (): Promise => { - if (typeof window === "undefined") return null - const deadline = Date.now() + 10_000 - while (!window.Clerk?.loaded && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 50)) - } - const session = window.Clerk?.session - if (!session) return null - try { - return (await session.getToken()) ?? null - } catch { - return null - } -} diff --git a/apps/web/src/lib/electric-fetch.ts b/apps/web/src/lib/electric-fetch.ts index 35d6e8293..2c25fd076 100644 --- a/apps/web/src/lib/electric-fetch.ts +++ b/apps/web/src/lib/electric-fetch.ts @@ -1,10 +1,11 @@ /** - * Authenticated fetch for Electric SQL. Attaches the Clerk bearer token and - * retries 5xx with jittered backoff so we don't hammer the proxy on outages. + * Authenticated fetch for Electric SQL. Attaches the Hazel access token (the proxy is on + * another host, so it cannot see the API's session cookie) and retries 5xx with jittered + * backoff so we don't hammer the proxy on outages. * Auth state is handled by the React tree — this just fetches. */ import { Effect, Schedule } from "effect" -import { getClerkToken } from "./clerk-token" +import { getAccessToken } from "./access-token" import { runtime } from "./services/common/runtime" const retrySchedule = Schedule.max([ @@ -15,7 +16,7 @@ const retrySchedule = Schedule.max([ const shouldRetry = (response: Response): boolean => response.status >= 500 && response.status < 600 const doFetch = async (input: RequestInfo | URL, init: RequestInit | undefined): Promise => { - const token = await getClerkToken() + const token = await getAccessToken() if (!token) return new Response(null, { status: 401 }) return fetch(input, { ...init, diff --git a/apps/web/src/lib/rivet-client.ts b/apps/web/src/lib/rivet-client.ts index c1dee9a67..95ad1d302 100644 --- a/apps/web/src/lib/rivet-client.ts +++ b/apps/web/src/lib/rivet-client.ts @@ -1,11 +1,8 @@ import { createClient } from "rivetkit/client" import type { Registry } from "@hazel/actors" -import { getClerkToken } from "~/lib/clerk-token" +import { getAccessToken } from "~/lib/access-token" -/** - * Get the current auth token (Clerk session JWT). - */ -export const getAccessToken = async (): Promise => getClerkToken() +export { getAccessToken } const RIVET_URL = import.meta.env.VITE_RIVET_URL || "http://localhost:6420" @@ -13,7 +10,7 @@ export const rivetClient = createClient(RIVET_URL) /** * Get or create a message actor with authentication. - * The Clerk session token is passed as a connection parameter. + * The Hazel access token is passed as a connection parameter. */ export async function getAuthenticatedMessageActor( key: string[], diff --git a/apps/web/src/lib/rpc-auth-middleware.ts b/apps/web/src/lib/rpc-auth-middleware.ts index 20185d7dd..e2ea10943 100644 --- a/apps/web/src/lib/rpc-auth-middleware.ts +++ b/apps/web/src/lib/rpc-auth-middleware.ts @@ -1,20 +1,15 @@ /** - * Client-side RPC auth middleware — attaches the Clerk session token as a - * Bearer header on outbound RPC requests. + * Client-side RPC auth middleware. Browsers authenticate RPC with the session cookie, + * which the protocol sends through `RpcCredentialsLive`, so requests pass through as-is. */ -import { Headers } from "effect/http" +import { FetchHttpClient } from "effect/http" import { RpcMiddleware } from "effect/rpc" import { AuthMiddleware } from "@hazel/domain/rpc" -import { Effect } from "effect" -import { getClerkToken } from "~/lib/clerk-token" +import { Layer } from "effect" export const AuthMiddlewareClientLive = RpcMiddleware.layerClient(AuthMiddleware, ({ request, next }) => - Effect.gen(function* () { - const token = yield* Effect.promise(() => getClerkToken()) - if (token) { - const newHeaders = Headers.set(request.headers, "authorization", `Bearer ${token}`) - return yield* next({ ...request, headers: newHeaders }) - } - return yield* next(request) - }), + next(request), ) + +/** Sends the API's session cookie with cross-origin RPC requests. */ +export const RpcCredentialsLive = Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }) diff --git a/apps/web/src/lib/services/common/rpc-atom-client.ts b/apps/web/src/lib/services/common/rpc-atom-client.ts index d063eda46..66abde87a 100644 --- a/apps/web/src/lib/services/common/rpc-atom-client.ts +++ b/apps/web/src/lib/services/common/rpc-atom-client.ts @@ -2,7 +2,7 @@ import { Reactivity } from "effect/reactivity" import { FetchHttpClient } from "effect/http" import { RpcClient as RpcClientBuilder, RpcSerialization } from "effect/rpc" import { AtomRpc } from "effect/reactivity" -import { AuthMiddlewareClientLive } from "~/lib/rpc-auth-middleware" +import { AuthMiddlewareClientLive, RpcCredentialsLive } from "~/lib/rpc-auth-middleware" import { AttachmentRpcs, BotRpcs, @@ -33,7 +33,10 @@ const httpUrl = `${backendUrl}/rpc` const BaseProtocolLive = RpcClientBuilder.layerProtocolHttp({ url: httpUrl, -}).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(RpcSerialization.layerNdjson)) +}).pipe( + Layer.provide(FetchHttpClient.layer.pipe(Layer.provide(RpcCredentialsLive))), + Layer.provide(RpcSerialization.layerNdjson), +) export const RpcProtocolLive = BaseProtocolLive diff --git a/bun.lock b/bun.lock index 451a5f00b..0b15a51c7 100644 --- a/bun.lock +++ b/bun.lock @@ -355,14 +355,17 @@ "apps/web-foldkit": { "name": "web-foldkit", "dependencies": { - "@clerk/shared": "4.8.3", "@effect/platform-browser": "catalog:effect", "@electric-sql/client": "1.5.28", "@foldkit/ui": "0.167.0", "@fontsource/inter": "^5.2.8", + "@hazel/auth": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", + "@simplewebauthn/browser": "^14.0.0", "@tanstack/db": "0.11.3", + "@yielded/auth": "0.1.0-beta.38", + "@yielded/auth-simplewebauthn": "0.1.0-beta.38", "effect": "catalog:effect", "embla-carousel": "8.6.0", "foldkit": "0.167.0", @@ -2112,6 +2115,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@14.0.0", "", {}, "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow=="], + "@simplewebauthn/server": ["@simplewebauthn/server@14.0.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "@peculiar/x509": "^2.1.0", "reflect-metadata": "^0.2.2" } }, "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q=="], "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], From 43699b55e9ace6707fc187012945ff03cda3a856 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 19:28:39 +0200 Subject: [PATCH 07/10] feat(auth): Electric proxy and actors verify Hazel access tokens Both now verify the ES256 access token from POST /auth/token against the API's JWKS, instead of a Clerk JWT. The token's subject is the Hazel user id, so the proxy no longer looks users up by Clerk id. When the key set can't be fetched, the proxy returns 503 so clients retry. The deploy sets API_BASE_URL for both. A plain-HTTP dev API needs the public key set inline in AUTH_ACCESS_TOKEN_JWKS, which generate-keys now prints. Co-Authored-By: Claude Opus 5.5 --- apps/actors/alchemy.run.ts | 7 + apps/electric-proxy/.env.example | 7 +- apps/electric-proxy/src/auth/user-auth.ts | 23 +-- .../e2e/electric-proxy-filters.e2e.test.ts | 9 +- apps/electric-proxy/src/handler.ts | 11 ++ apps/electric-proxy/src/index.ts | 9 +- .../src/tables/user-tables.test.ts | 2 - apps/electric-proxy/src/worker.ts | 14 +- bun.lock | 2 +- infra/hazel-auth-rollout.md | 14 +- packages/actors/package.json | 2 +- packages/actors/src/auth/config-service.ts | 5 - .../src/auth/token-validation-service.ts | 64 ++++---- packages/actors/src/auth/types.ts | 8 +- packages/auth/package.json | 1 + packages/auth/scripts/generate-keys.ts | 6 + packages/auth/src/consumers/proxy-auth.ts | 144 +++++------------- .../auth/src/yielded/access-token.test.ts | 62 ++++++++ packages/auth/src/yielded/access-token.ts | 85 ++++++++++- 19 files changed, 276 insertions(+), 199 deletions(-) create mode 100644 packages/auth/src/yielded/access-token.test.ts diff --git a/apps/actors/alchemy.run.ts b/apps/actors/alchemy.run.ts index 1df82e328..327ad21f4 100644 --- a/apps/actors/alchemy.run.ts +++ b/apps/actors/alchemy.run.ts @@ -4,6 +4,7 @@ * `ACTOR_DO` -> `ActorHandler` mapping (alchemy matches the live class by binding name). */ import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare" +import { derived, merge, optionalPlain } from "@hazel/infra/env" import * as Cloudflare from "alchemy/Cloudflare" import { Effect } from "effect" @@ -25,6 +26,12 @@ export default Effect.gen(function* () { ACTOR_KV: actorKv, NODE_ENV: stack.stage.kind === "dev" ? "development" : "production", RIVET_PUBLIC_ENDPOINT: stack.urls.rivet, + ...(yield* merge( + // Issuer and JWKS of the access tokens users connect with; also where bot + // tokens are validated. A plain-HTTP dev API needs the key set inline. + derived("API_BASE_URL", stack.urls.api), + optionalPlain("AUTH_ACCESS_TOKEN_JWKS"), + )), }, observability: { enabled: true, diff --git a/apps/electric-proxy/.env.example b/apps/electric-proxy/.env.example index 17e8177d7..e05894a3b 100644 --- a/apps/electric-proxy/.env.example +++ b/apps/electric-proxy/.env.example @@ -13,8 +13,11 @@ ELECTRIC_URL=http://localhost:3333 # ELECTRIC_SOURCE_ID= # ELECTRIC_SOURCE_SECRET= -# Clerk Authentication (validates Bearer JWTs) -CLERK_SECRET_KEY=sk_test_your_clerk_secret_here +# Hazel access tokens (Bearer JWTs from the API's POST /auth/token). The issuer is the API +# origin; the public keys come from its JWKS, which must be HTTPS. For a local plain-HTTP API, +# set AUTH_ACCESS_TOKEN_JWKS to the key set `bun run --cwd packages/auth generate-keys` prints. +API_BASE_URL=http://localhost:3003 +# AUTH_ACCESS_TOKEN_JWKS={"keys":[...]} # CORS ALLOWED_ORIGIN=http://localhost:3000 diff --git a/apps/electric-proxy/src/auth/user-auth.ts b/apps/electric-proxy/src/auth/user-auth.ts index ad95eca5e..373b2f9bd 100644 --- a/apps/electric-proxy/src/auth/user-auth.ts +++ b/apps/electric-proxy/src/auth/user-auth.ts @@ -2,26 +2,19 @@ import { ProxyAuth, ProxyAuthenticationError } from "@hazel/auth/proxy" import type { UserId } from "@hazel/schema" import { Effect } from "effect" -/** - * Authenticated user context extracted from the Clerk JWT. - */ +/** The signed-in user a shape request is filtered for. */ export interface AuthenticatedUser { - /** Clerk user ID (e.g., user_2abc...) — stored in users.externalId. */ - externalId: string - /** Internal Hazel database user UUID. */ + /** Hazel's `users.id`, the access token's subject. */ internalUserId: UserId - email: string } /** - * Validate authentication and return authenticated user. - * Requires a Bearer token (JWT) in the Authorization header. - * Uses @hazel/auth for JWT validation with user lookup caching. + * Validate authentication and return the authenticated user. + * Requires the Hazel access token (`POST /auth/token` on the API) as a Bearer token. */ export const validateSession = Effect.fn("ElectricProxy.validateSession")(function* (request: Request) { const proxyAuth = yield* ProxyAuth - // Require Bearer token const authHeader = request.headers.get("Authorization") if (!authHeader?.startsWith("Bearer ")) { yield* Effect.annotateCurrentSpan("auth.header.present", false) @@ -35,11 +28,5 @@ export const validateSession = Effect.fn("ElectricProxy.validateSession")(functi yield* Effect.annotateCurrentSpan("auth.header.present", true) yield* Effect.annotateCurrentSpan("auth.scheme", "bearer") - const authContext = yield* proxyAuth.validateBearerToken(token) - - return { - externalId: authContext.externalId, - internalUserId: authContext.internalUserId, - email: authContext.email, - } satisfies AuthenticatedUser + return (yield* proxyAuth.validateBearerToken(token)) satisfies AuthenticatedUser }) diff --git a/apps/electric-proxy/src/e2e/electric-proxy-filters.e2e.test.ts b/apps/electric-proxy/src/e2e/electric-proxy-filters.e2e.test.ts index d60e266e5..c7c4eb814 100644 --- a/apps/electric-proxy/src/e2e/electric-proxy-filters.e2e.test.ts +++ b/apps/electric-proxy/src/e2e/electric-proxy-filters.e2e.test.ts @@ -594,7 +594,7 @@ const createFixture = (): Fixture => { externalId: `user_${ids.users.viewer}`, internalUserId: ids.users.viewer as AuthenticatedUser["internalUserId"], email: `viewer+${runId}@e2e.test`, - } satisfies AuthenticatedUser + } return { runId, @@ -823,9 +823,10 @@ const startProxy = async () => { IS_DEV: "true", ELECTRIC_URL: ELECTRIC_BASE_URL, REDIS_URL: "redis://localhost:6380", - // Placeholder — user-visibility specs hit Electric directly; bot - // tests use bot tokens. Proxy just needs the var set to boot. - CLERK_SECRET_KEY: "sk_test_e2e_placeholder", + // User-visibility specs hit Electric directly and bot tests use bot tokens, so + // no access token verifies; the verifier just needs a key set to boot. + API_BASE_URL: "http://localhost:3003", + AUTH_ACCESS_TOKEN_JWKS: '{"keys":[]}', ALLOWED_ORIGIN: "http://localhost:3000", }, stdio: ["ignore", "pipe", "pipe"], diff --git a/apps/electric-proxy/src/handler.ts b/apps/electric-proxy/src/handler.ts index 6921ad648..42a15c637 100644 --- a/apps/electric-proxy/src/handler.ts +++ b/apps/electric-proxy/src/handler.ts @@ -166,6 +166,17 @@ export const handleUserRequest = (request: Request) => { ) }), ), + // The API's signing keys could not be fetched → 503, which the web client retries + Effect.catchTag("ProxyAuthUnavailableError", (error) => + Effect.gen(function* () { + yield* annotateHandledError(503, "ProxyAuthUnavailableError") + yield* Effect.logWarning("Access token keys unavailable", { detail: error.detail }) + return new Response(JSON.stringify({ error: error.message, detail: error.detail }), { + status: 503, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }) + }), + ), // Upstream errors → 502 Effect.catchTag("ElectricProxyError", (error: ElectricProxyError) => Effect.gen(function* () { diff --git a/apps/electric-proxy/src/index.ts b/apps/electric-proxy/src/index.ts index b55ff3c00..c5a39e06d 100644 --- a/apps/electric-proxy/src/index.ts +++ b/apps/electric-proxy/src/index.ts @@ -61,13 +61,8 @@ const CacheLive = AccessContextCache.layer.pipe( Layer.provide(ProxyConfigService.layer), ) -// ProxyAuth layer requires ResultPersistence for session caching and Database for user lookup -// ProxyAuth.layer includes SessionValidator.layer via dependencies -const ProxyAuthLive = ProxyAuth.layer.pipe( - Layer.provide(RedisPersistenceLive), - Layer.provide(DatabaseLive), - Layer.provide(ProxyConfigService.layer), -) +// Verifies Hazel access tokens against the API's key set (API_BASE_URL / AUTH_ACCESS_TOKEN_JWKS) +const ProxyAuthLive = ProxyAuth.layer const MainLive = DatabaseLive.pipe( Layer.provideMerge(ProxyConfigService.layer), diff --git a/apps/electric-proxy/src/tables/user-tables.test.ts b/apps/electric-proxy/src/tables/user-tables.test.ts index 6d8872187..0212705b2 100644 --- a/apps/electric-proxy/src/tables/user-tables.test.ts +++ b/apps/electric-proxy/src/tables/user-tables.test.ts @@ -4,9 +4,7 @@ import type { AuthenticatedUser } from "../auth/user-auth" import { getWhereClauseForTable } from "./user-tables" const testUser: AuthenticatedUser = { - externalId: "user_test", internalUserId: "00000000-0000-0000-0000-0000000000a1" as AuthenticatedUser["internalUserId"], - email: "viewer@hazel.test", } const run = (effect: Effect.Effect) => Effect.runPromise(effect) diff --git a/apps/electric-proxy/src/worker.ts b/apps/electric-proxy/src/worker.ts index 4f4174459..61b9d66d8 100644 --- a/apps/electric-proxy/src/worker.ts +++ b/apps/electric-proxy/src/worker.ts @@ -1,5 +1,5 @@ /** - * The electric-proxy Worker: authenticates Clerk users and bots, pins each shape's where-clause + * The electric-proxy Worker: authenticates Hazel users (access tokens) and bots, pins each shape's where-clause * and forwards to Electric, streaming the response through. Electric is the self-hosted * container's Durable Object (`ELECTRIC`), or `ELECTRIC_URL` (docker Electric under `alchemy dev`). */ @@ -8,7 +8,7 @@ import { Database } from "@hazel/db" import { layerKvResultPersistence } from "@hazel/effect-cloudflare/KvPersistence" import { cachedRecoverable } from "@hazel/infra/cached-recoverable" import { HAZEL_DB_BINDING, HazelStack, hazelWorkerProps, readHazelDbBinding } from "@hazel/infra/cloudflare" -import { merge, requireSecretEntry, telemetryEnv } from "@hazel/infra/env" +import { derived, merge, optionalPlain, telemetryEnv } from "@hazel/infra/env" import { forIsolate, isolateContext } from "@hazel/infra/worker-http" import { workerEnvLayer } from "@hazel/infra/worker-runtime" import type { KVNamespace } from "@cloudflare/workers-types" @@ -20,7 +20,7 @@ import { AccessContextCacheService } from "./cache" import { handleRequest } from "./handler" import { type ElectricNamespace, ElectricUpstream } from "./proxy/electric-upstream" -/** KV namespace backing the bot access-context and Clerk user-lookup caches (was Redis). */ +/** KV namespace backing the bot access-context cache (was Redis). */ const PROXY_CACHE_BINDING = "PROXY_CACHE" /** The Electric container's Durable Object namespace, bound from the `electric` Worker. */ const ELECTRIC_BINDING = "ELECTRIC" @@ -41,7 +41,13 @@ const props = Effect.gen(function* () { [HAZEL_DB_BINDING]: stack.db.hyperdrive, [PROXY_CACHE_BINDING]: yield* ProxyCache, ...(yield* electricProxyEnv(stack)), - ...(yield* merge(requireSecretEntry("CLERK_SECRET_KEY"), telemetryEnv(stage))), + ...(yield* merge( + // Access tokens are issued and signed by the API; the public keys come from its + // JWKS, or inline for a plain-HTTP dev API (`generate-keys` prints them). + derived("API_BASE_URL", stack.urls.api), + optionalPlain("AUTH_ACCESS_TOKEN_JWKS"), + telemetryEnv(stage), + )), }, } }) diff --git a/bun.lock b/bun.lock index 0b15a51c7..deb8a7a81 100644 --- a/bun.lock +++ b/bun.lock @@ -489,7 +489,7 @@ "name": "@hazel/actors", "version": "0.0.1", "dependencies": { - "@clerk/backend": "^3.3.0", + "@hazel/auth": "workspace:*", "@hazel/rivet-effect": "workspace:*", "@hazel/schema": "workspace:*", "effect": "catalog:effect", diff --git a/infra/hazel-auth-rollout.md b/infra/hazel-auth-rollout.md index f403cbbb0..9400a9f0f 100644 --- a/infra/hazel-auth-rollout.md +++ b/infra/hazel-auth-rollout.md @@ -34,12 +34,14 @@ Active users in the last 90 days: 60, of whom 53 have GitHub/Google linked. as `AUTH_GOOGLE_CLIENT_ID` / `AUTH_GOOGLE_CLIENT_SECRET`. - Setting `AUTH_GITHUB_CLIENT_ID` turns the routes on at the next deploy. Do this only once the web app is ready. -2. **Web app (`apps/web-foldkit`).** Build these pages: sign-in (GitHub, Google, passkey), - `/auth/register` (name form), `/auth/continue?provider=` (restarts sign-in), and passkey - settings. Then switch the RPC client to cookie credentials, and the Electric and actors - clients to `POST /auth/token`. -3. **Electric proxy and actors.** Verify the access token against - `https://api.hazel.sh/.well-known/jwks.json` instead of Clerk. +2. **Web app (`apps/web-foldkit`): done.** The sign-in pages are in place: sign-in (GitHub, + Google, passkey), `/auth/register` (name form) and `/auth/continue?provider=` (restarts + sign-in). The RPC and HTTP API clients send the session cookie. The Electric and actors + clients use `POST /auth/token`. Passkey enrollment settings are still to do. +3. **Electric proxy and actors: done.** Both verify the access token against + `/.well-known/jwks.json`, and the deploy sets `API_BASE_URL`. The JWKS + fetch is HTTPS-only, so for a plain-HTTP dev API set `AUTH_ACCESS_TOKEN_JWKS` (printed by + `generate-keys`) for both. `CLERK_SECRET_KEY` is no longer needed by either. 4. **Preview stage.** Do one real GitHub and Google sign-in through Hyperdrive before prd. 5. **Organizations, invites and profile editing.** Move these off Clerk. 6. **Cutover.** Re-run the import, deploy with `AUTH_GITHUB_CLIENT_ID` set, then switch the web diff --git a/packages/actors/package.json b/packages/actors/package.json index e58a5d8f8..daf6de9ae 100644 --- a/packages/actors/package.json +++ b/packages/actors/package.json @@ -13,7 +13,7 @@ "test": "vitest run" }, "dependencies": { - "@clerk/backend": "^3.3.0", + "@hazel/auth": "workspace:*", "@hazel/rivet-effect": "workspace:*", "@hazel/schema": "workspace:*", "effect": "catalog:effect", diff --git a/packages/actors/src/auth/config-service.ts b/packages/actors/src/auth/config-service.ts index 5ce1bab88..d80115391 100644 --- a/packages/actors/src/auth/config-service.ts +++ b/packages/actors/src/auth/config-service.ts @@ -4,8 +4,6 @@ import { Context, Config, Effect, Layer, Option, Redacted } from "effect" * Configuration required for token validation. */ export interface TokenValidationConfig { - /** Clerk secret key for JWT validation (sk_live_* / sk_test_*). */ - readonly clerkSecretKey: Option.Option /** Backend URL for bot token validation. */ readonly backendUrl: Option.Option /** Internal secret for server-to-server auth (optional). */ @@ -18,8 +16,6 @@ export class TokenValidationConfigService extends Context.Service Config.String("API_BASE_URL")), @@ -31,7 +27,6 @@ export class TokenValidationConfigService extends Context.Service, backendUrl: backendUrl as Option.Option, internalSecret: internalSecret as Option.Option, } diff --git a/packages/actors/src/auth/token-validation-service.ts b/packages/actors/src/auth/token-validation-service.ts index 5ae24f3dc..6bf84808e 100644 --- a/packages/actors/src/auth/token-validation-service.ts +++ b/packages/actors/src/auth/token-validation-service.ts @@ -1,6 +1,6 @@ -import { verifyToken } from "@clerk/backend" +import { AccessTokenVerifier } from "@hazel/auth/access-token" import { HttpClient, HttpClientRequest } from "effect/http" -import { ClerkJwtClaims } from "@hazel/schema" +import { UserId } from "@hazel/schema" import { Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { TokenValidationConfigService } from "./config-service" import { BotTokenValidationError, ConfigError, InvalidTokenFormatError, JwtValidationError } from "./errors" @@ -21,56 +21,45 @@ function isBotToken(token: string): boolean { } /** - * Service for validating authentication tokens (Clerk JWT and bot tokens). + * Service for validating authentication tokens (Hazel access tokens and bot tokens). */ export class TokenValidationService extends Context.Service()( "TokenValidationService", { make: Effect.gen(function* () { const config = yield* TokenValidationConfigService - const decodeClaims = Schema.decodeUnknownEffect(ClerkJwtClaims) + const verifier = yield* AccessTokenVerifier + const decodeUserId = Schema.decodeUnknownEffect(UserId) const decodeBotValidationResponse = Schema.decodeUnknownEffect(BotTokenValidationResponseSchema) + /** A Hazel access token (`POST /auth/token` on the API), verified against its key set. */ const validateJwt = ( token: string, ): Effect.Effect => Effect.gen(function* () { - const clerkSecretKey = yield* Option.match(config.clerkSecretKey, { - onNone: () => - Effect.fail( - new ConfigError({ - message: - "CLERK_SECRET_KEY environment variable is required for JWT actor authentication", - }), - ), - onSome: Effect.succeed, - }) - - const payload = yield* Effect.tryPromise({ - try: () => verifyToken(token, { secretKey: Redacted.value(clerkSecretKey) }), - catch: (error) => - new JwtValidationError({ - message: `Clerk JWT verification failed: ${error}`, - }), - }) - - const claims = yield* decodeClaims(payload).pipe( + const access = yield* verifier.verify(token).pipe( + Effect.catchTags({ + AccessTokenInvalid: (error) => + Effect.fail( + new JwtValidationError({ + message: `Access token verification failed: ${error.reason}`, + }), + ), + AccessTokenUnavailable: (error) => + Effect.fail( + new ConfigError({ + message: `Access token keys unavailable: ${error.reason}`, + }), + ), + }), + ) + const userId = yield* decodeUserId(access.userId).pipe( Effect.mapError( - (error) => - new JwtValidationError({ - message: `Invalid JWT claims: ${error.message}`, - }), + () => + new JwtValidationError({ message: "Access token subject is not a user id" }), ), ) - - const role: "admin" | "member" = claims.org_role === "org:admin" ? "admin" : "member" - - return { - type: "user" as const, - externalId: claims.sub, - externalOrganizationId: claims.org_id ?? null, - role, - } + return { type: "user" as const, userId } }) const validateBotToken = ( @@ -182,6 +171,7 @@ export class TokenValidationService extends Context.Service()( "ProxyAuthenticationError", @@ -13,123 +10,62 @@ export class ProxyAuthenticationError extends Schema.TaggedError()( + "ProxyAuthUnavailableError", + { + message: Schema.String, + detail: Schema.optional(Schema.String), + }, +) {} + +const decodeUserId = Schema.decodeUnknownEffect(UserId) + /** * Electric-proxy authentication service. * - * Verifies a Clerk bearer JWT and resolves it to an internal Hazel user. Does - * NOT upsert users — if the user isn't already in the DB (via Clerk webhook - * sync), the request is rejected. + * Verifies the Hazel access token the web app gets from `POST /auth/token`. Its subject is + * the user's `users.id`, so no lookup is needed; the API only issues it for a live session. */ export class ProxyAuth extends Context.Service()("@hazel/auth/ProxyAuth", { make: Effect.gen(function* () { - const userLookupCache = yield* UserLookupCache - const db = yield* Database.Database - const decodeClerkClaims = Schema.decodeUnknownEffect(ClerkJwtClaims) - const clerkSecretKey = yield* Config.Redacted("CLERK_SECRET_KEY") - - const lookupUser = Effect.fn("ProxyAuth.lookupUser")(function* (externalId: string) { - const cached = yield* userLookupCache - .get(externalId) - .pipe( - Effect.catch((error) => - Effect.logWarning("User lookup cache error", error).pipe( - Effect.map(() => - Option.none<{ - internalUserId: (typeof schema.usersTable.$inferSelect)["id"] - }>(), - ), - ), - ), - ) - - if (Option.isSome(cached)) { - yield* Effect.annotateCurrentSpan("cache.result", "hit") - return Option.some(cached.value.internalUserId) - } + const verifier = yield* AccessTokenVerifier - const userResult = yield* db - .execute((client) => - client - .select({ id: schema.usersTable.id }) - .from(schema.usersTable) - .where(eq(schema.usersTable.externalId, externalId)) - .limit(1), - ) - .pipe( - Effect.catchTag("DatabaseError", (error) => + const validateBearerToken = Effect.fn("ProxyAuth.validateBearerToken")(function* ( + bearerToken: string, + ) { + const access = yield* verifier.verify(bearerToken).pipe( + Effect.catchTags({ + AccessTokenInvalid: (error) => Effect.fail( new ProxyAuthenticationError({ - message: "Failed to lookup user in database", - detail: error.message, + message: "Access token verification failed", + detail: error.reason, }), ), - ), - ) - const userOption = Option.fromNullishOr(userResult[0]) - - if (Option.isSome(userOption)) { - yield* userLookupCache - .set(externalId, userOption.value.id) - .pipe(Effect.catch((error) => Effect.logWarning("Failed to cache user lookup", error))) - } - - return Option.map(userOption, (user) => user.id) - }) - - const validateBearerToken = Effect.fn("ProxyAuth.validateBearerToken")(function* ( - bearerToken: string, - ) { - const payload = yield* Effect.tryPromise({ - try: () => verifyToken(bearerToken, { secretKey: Redacted.value(clerkSecretKey) }), - catch: (error) => - new ProxyAuthenticationError({ - message: "Clerk JWT verification failed", - detail: String(error), - }), - }) + AccessTokenUnavailable: (error) => + Effect.fail( + new ProxyAuthUnavailableError({ + message: "Access token keys are unavailable", + detail: error.reason, + }), + ), + }), + ) - const claims = yield* decodeClerkClaims(payload).pipe( + const internalUserId = yield* decodeUserId(access.userId).pipe( Effect.mapError( - (error) => - new ProxyAuthenticationError({ - message: "Invalid Clerk JWT claims", - detail: String(error), - }), + () => new ProxyAuthenticationError({ message: "Access token subject is not a user id" }), ), ) - - const userIdOption = yield* lookupUser(claims.sub).pipe( - Effect.withSpan("ProxyAuth.lookupUser", { - attributes: { "clerk.user_id": claims.sub }, - }), - ) - - if (Option.isNone(userIdOption)) { - yield* Effect.annotateCurrentSpan("user.found", false) - return yield* new ProxyAuthenticationError({ - message: "User not found in database", - detail: `User must be created via Clerk webhook first. Clerk ID: ${claims.sub}`, - }) - } - - yield* Effect.annotateCurrentSpan("user.found", true) - yield* Effect.annotateCurrentSpan("user.id", userIdOption.value) - - return { - externalId: claims.sub, - internalUserId: userIdOption.value, - email: claims.email ?? "", - organizationId: undefined, - role: claims.org_role === "org:admin" ? "admin" : "member", - } satisfies AuthenticatedUserContext + yield* Effect.annotateCurrentSpan("user.id", internalUserId) + return { internalUserId } }) - return { - validateBearerToken, - } + return { validateBearerToken } }), }) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(UserLookupCache.layer)) + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(AccessTokenVerifier.layer)) } export const ProxyAuthLive = ProxyAuth.layer diff --git a/packages/auth/src/yielded/access-token.test.ts b/packages/auth/src/yielded/access-token.test.ts new file mode 100644 index 000000000..50c259243 --- /dev/null +++ b/packages/auth/src/yielded/access-token.test.ts @@ -0,0 +1,62 @@ +import { ConfigProvider, Effect, Exit, Layer, Redacted } from "effect" +import { describe, expect, test } from "vitest" +import { AccessTokenVerifier, generateSigningKey, makeIssuer, publicJwk } from "./access-token.ts" +import { CryptoLive } from "./server.ts" + +/** The verifier the Electric proxy and actors use, configured as they are on a stage. */ + +const api = "https://api.hazel.test" +const userId = "00000000-0000-4000-8000-000000000001" + +const keyPair = async (kid: string) => { + const pair = await Effect.runPromise(generateSigningKey(kid).pipe(Effect.provide(CryptoLive))) + return { kid, privateJwk: pair.privateKey } +} + +const mint = async (issuer: string, key: Awaited>) => { + const tokens = await Effect.runPromise(makeIssuer(issuer, key).pipe(Effect.provide(CryptoLive))) + const token = await Effect.runPromise( + tokens.mint({ subjectId: userId, sessionId: "s1" }).pipe(Effect.provide(CryptoLive)), + ) + return Redacted.value(token) +} + +const verify = async (token: string, key: Awaited>) => { + const jwk = await Effect.runPromise(publicJwk(key).pipe(Effect.provide(CryptoLive))) + const config = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + API_BASE_URL: api, + AUTH_ACCESS_TOKEN_JWKS: JSON.stringify({ keys: [jwk] }), + }), + ) + return Effect.runPromiseExit( + Effect.gen(function* () { + const verifier = yield* AccessTokenVerifier + return yield* verifier.verify(token) + }).pipe(Effect.provide(AccessTokenVerifier.layer.pipe(Layer.provide(config)))), + ) +} + +const failureTag = (exit: Exit.Exit) => + Exit.isFailure(exit) + ? exit.cause.reasons.map((reason) => ("error" in reason ? reason.error._tag : reason._tag)) + : [] + +describe("AccessTokenVerifier", () => { + test("a token the API minted resolves to its user", async () => { + const key = await keyPair("k1") + const exit = await verify(await mint(api, key), key) + expect(exit).toEqual(Exit.succeed({ userId, sessionId: "s1" })) + }) + + test("another stage's token is rejected", async () => { + const key = await keyPair("k1") + const exit = await verify(await mint("https://api.other.test", key), key) + expect(failureTag(exit)).toEqual(["AccessTokenInvalid"]) + }) + + test("a token signed with another key is rejected", async () => { + const exit = await verify(await mint(api, await keyPair("k1")), await keyPair("k1")) + expect(failureTag(exit)).toEqual(["AccessTokenInvalid"]) + }) +}) diff --git a/packages/auth/src/yielded/access-token.ts b/packages/auth/src/yielded/access-token.ts index a8d84bfdc..d06f1993a 100644 --- a/packages/auth/src/yielded/access-token.ts +++ b/packages/auth/src/yielded/access-token.ts @@ -1,9 +1,11 @@ -import { InvalidKey } from "@yielded/jose/Errors" +import * as PortableCrypto from "@yielded/crypto/Portable" +import { InvalidKey, type JwksUnavailable } from "@yielded/jose/Errors" import * as Jwk from "@yielded/jose/Jwk" import * as Jwks from "@yielded/jose/Jwks" import * as Jwt from "@yielded/jose/Jwt" import { Signature } from "@yielded/crypto/Signature" -import { DateTime, Effect, Redacted, Schema } from "effect" +import { Config, Context, DateTime, Effect, Layer, Option, Redacted, Schema } from "effect" +import { FetchHttpClient } from "effect/http" /** * Short-lived access token for services on other hosts (electric.hazel.sh, rivet.hazel.sh). @@ -85,4 +87,83 @@ export const verifyAccessToken = (issuer: string, token: string) => /** A new ES256 key pair; the private JWK goes in `AUTH_ACCESS_TOKEN_PRIVATE_JWK`. */ export const generateSigningKey = (kid: string) => Jwk.generateKeyPair({ algorithm: "ES256", kid }) +/** The token is missing, malformed, expired, for another stage, or not signed by the API. */ +export class AccessTokenInvalid extends Schema.TaggedError()("AccessTokenInvalid", { + reason: Schema.String, +}) {} + +/** The API's key set could not be fetched, so no token can be checked right now. */ +export class AccessTokenUnavailable extends Schema.TaggedError()( + "AccessTokenUnavailable", + { reason: Schema.String }, +) {} + +export interface VerifiedAccess { + /** Hazel's `users.id`. */ + readonly userId: string + readonly sessionId: string +} + +const VerifierConfig = Config.all({ + apiBaseUrl: Config.String("API_BASE_URL"), + /** + * The public key set as JSON. Optional in production, where the verifier fetches + * `/.well-known/jwks.json` (HTTPS only); needed for a plain-HTTP API in dev. + */ + inlineJwks: Config.option(Config.String("AUTH_ACCESS_TOKEN_JWKS")), +}) + +/** Signature verification only: WebCrypto through the library's portable layer. */ +const VerifierCryptoLive = Layer.suspend(() => PortableCrypto.layerWithoutKdf(globalThis.crypto.subtle)) + +const makeVerifier = (issuer: string) => + Effect.gen(function* () { + const services = yield* Effect.context() + + const verify = Effect.fn("AccessTokenVerifier.verify")(function* (token: string) { + const verified = yield* verifyAccessToken(issuer, token).pipe( + Effect.provideContext(services), + Effect.catchTag("JoseJwksUnavailable", (error) => + Effect.fail(new AccessTokenUnavailable({ reason: error.reason })), + ), + Effect.catchIf( + (error) => error._tag !== "AccessTokenUnavailable", + (error) => Effect.fail(new AccessTokenInvalid({ reason: error._tag })), + ), + ) + return { userId: verified.claims.sub, sessionId: verified.claims.sid } satisfies VerifiedAccess + }) + + return { verify } as const + }) + +/** + * Verifies the access tokens `POST /auth/token` issues, for the Electric proxy and the + * actors. The issuer is the API origin, so a token only verifies on its own stage. + */ +export class AccessTokenVerifier extends Context.Service< + AccessTokenVerifier, + Effect.Success> +>()("@hazel/auth/AccessTokenVerifier") { + static readonly layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* VerifierConfig + const issuer = new URL(config.apiBaseUrl).origin + const keySet: Layer.Layer = Option.match( + config.inlineJwks, + { + onSome: (json) => Jwks.layerLocal(JSON.parse(json)), + onNone: () => + Jwks.layerRemote({ url: `${issuer}/.well-known/jwks.json` }).pipe( + Layer.provide(FetchHttpClient.layer), + ), + }, + ) + return Layer.effect(AccessTokenVerifier, makeVerifier(issuer)).pipe( + Layer.provide(Layer.merge(keySet, VerifierCryptoLive)), + ) + }), + ) +} + export { Jwks } From 4a101a78a8dd9b784df67c53cf22968bbd7ba402 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 20:24:48 +0200 Subject: [PATCH 08/10] feat: move organizations, invitations and profiles off Clerk Organizations - organization.create no longer creates a Clerk organization - the web app creates workspaces with its own form, in onboarding and the empty organization picker, instead of Clerk's widget Invitations - new invitation.* RPCs: create, list, revoke, a public preview, and accept - stored in a new organization_invitations table; the emailed link carries a random token and only its SHA-256 hash is stored - accepting requires the signed-in email to match the invited address - emails go out through a Cloudflare Email Sending binding on the api Worker; on Bun, and without the binding, the link is logged - /invite/:token is the accept page; settings, the invite modal and onboarding now use the RPCs Profiles - user.update now takes firstName, lastName and avatarUrl - the profile page and onboarding save names through user.update and upload avatars to our storage The web app no longer references clerk-js. Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/app.ts | 6 + apps/backend/src/index.ts | 3 + .../backend/src/policies/invitation-policy.ts | 27 ++ apps/backend/src/rpc/handlers/invitations.ts | 16 + .../backend/src/rpc/handlers/organizations.ts | 152 +++------- apps/backend/src/rpc/server.ts | 5 + apps/backend/src/services/invitations.test.ts | 205 +++++++++++++ apps/backend/src/services/invitations.ts | 273 ++++++++++++++++++ .../src/services/invite-mailer.test.ts | 18 ++ apps/backend/src/services/invite-mailer.ts | 99 +++++++ apps/backend/src/worker.ts | 4 +- apps/backend/src/worker/env.ts | 2 + apps/backend/src/worker/platform.ts | 12 + apps/web-foldkit/src/data/invitations.ts | 74 +++++ .../src/overlay/modal/email-invite-clerk.ts | 57 ---- .../src/overlay/modal/email-invite.ts | 57 ++-- .../src/page/auth/clerk-mount.test.ts | 40 --- apps/web-foldkit/src/page/auth/clerk-mount.ts | 94 ------ apps/web-foldkit/src/page/create-workspace.ts | 179 ++++++++++++ apps/web-foldkit/src/page/invite/command.ts | 72 +++++ apps/web-foldkit/src/page/invite/index.ts | 8 + apps/web-foldkit/src/page/invite/message.ts | 15 + apps/web-foldkit/src/page/invite/model.ts | 27 ++ .../web-foldkit/src/page/invite/scene.test.ts | 82 ++++++ apps/web-foldkit/src/page/invite/update.ts | 37 +++ apps/web-foldkit/src/page/invite/view.ts | 119 ++++++++ .../page/my-settings/profile/avatar-view.ts | 2 +- .../src/page/my-settings/profile/command.ts | 84 ++---- .../my-settings/profile/profile.scene.test.ts | 10 +- .../my-settings/profile/profile.story.test.ts | 4 +- .../src/page/my-settings/profile/update.ts | 13 +- apps/web-foldkit/src/page/my-settings/user.ts | 3 + apps/web-foldkit/src/page/onboarding/clerk.ts | 26 -- .../src/page/onboarding/command.ts | 59 ++-- .../src/page/onboarding/scene.test.ts | 10 +- .../onboarding/setup-organization/index.ts | 48 ++- .../setup-organization/scene.test.ts | 51 ++-- .../src/page/onboarding/story.test.ts | 20 +- .../src/page/onboarding/transition.ts | 1 - .../web-foldkit/src/page/onboarding/update.ts | 14 +- apps/web-foldkit/src/page/registry.ts | 2 + .../src/page/select-organization/message.ts | 4 +- .../src/page/select-organization/model.ts | 3 + .../page/select-organization/scene.test.ts | 20 +- .../src/page/select-organization/update.ts | 16 +- .../src/page/select-organization/view.ts | 18 +- .../page/settings/invitations/clerk.test.ts | 43 --- .../src/page/settings/invitations/clerk.ts | 96 ------ .../src/page/settings/invitations/message.ts | 3 +- .../src/page/settings/invitations/model.ts | 12 +- .../page/settings/invitations/page.test.ts | 11 +- .../page/settings/invitations/scene.test.ts | 17 +- .../page/settings/invitations/story.test.ts | 53 ++-- .../src/page/settings/invitations/update.ts | 38 ++- .../src/page/settings/invitations/view.ts | 2 +- apps/web-foldkit/src/page/settings/upload.ts | 10 +- apps/web-foldkit/src/route.test.ts | 1 + apps/web-foldkit/src/route.ts | 5 + apps/web-foldkit/src/rpc.ts | 2 + apps/web-foldkit/src/vite-env.d.ts | 7 - infra/hazel-auth-rollout.md | 10 +- packages/backend-core/src/index.ts | 1 + .../backend-core/src/repositories/index.ts | 1 + .../src/repositories/invitation-repo.ts | 152 ++++++++++ .../2026-10-13-organization-invitations.sql | 33 +++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/invitations.ts | 40 +++ packages/domain/src/rpc/index.ts | 2 + packages/domain/src/rpc/invitations.ts | 131 +++++++++ packages/domain/src/rpc/users.ts | 11 +- packages/schema/src/ids.ts | 8 + 71 files changed, 2038 insertions(+), 743 deletions(-) create mode 100644 apps/backend/src/policies/invitation-policy.ts create mode 100644 apps/backend/src/rpc/handlers/invitations.ts create mode 100644 apps/backend/src/services/invitations.test.ts create mode 100644 apps/backend/src/services/invitations.ts create mode 100644 apps/backend/src/services/invite-mailer.test.ts create mode 100644 apps/backend/src/services/invite-mailer.ts create mode 100644 apps/web-foldkit/src/data/invitations.ts delete mode 100644 apps/web-foldkit/src/overlay/modal/email-invite-clerk.ts delete mode 100644 apps/web-foldkit/src/page/auth/clerk-mount.test.ts delete mode 100644 apps/web-foldkit/src/page/auth/clerk-mount.ts create mode 100644 apps/web-foldkit/src/page/create-workspace.ts create mode 100644 apps/web-foldkit/src/page/invite/command.ts create mode 100644 apps/web-foldkit/src/page/invite/index.ts create mode 100644 apps/web-foldkit/src/page/invite/message.ts create mode 100644 apps/web-foldkit/src/page/invite/model.ts create mode 100644 apps/web-foldkit/src/page/invite/scene.test.ts create mode 100644 apps/web-foldkit/src/page/invite/update.ts create mode 100644 apps/web-foldkit/src/page/invite/view.ts delete mode 100644 apps/web-foldkit/src/page/onboarding/clerk.ts delete mode 100644 apps/web-foldkit/src/page/settings/invitations/clerk.test.ts delete mode 100644 apps/web-foldkit/src/page/settings/invitations/clerk.ts create mode 100644 packages/backend-core/src/repositories/invitation-repo.ts create mode 100644 packages/db/sql/2026-10-13-organization-invitations.sql create mode 100644 packages/db/src/schema/invitations.ts create mode 100644 packages/domain/src/rpc/invitations.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 73e585a5e..c62c893ee 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -24,6 +24,7 @@ import { ConnectInviteRepo, ConnectParticipantRepo, CustomEmojiRepo, + InvitationRepo, ChannelWebhookRepo, GitHubSubscriptionRepo, IntegrationConnectionRepo, @@ -48,6 +49,8 @@ import { HazelApi } from "./api" import { HttpApiRoutes } from "./http" import { AttachmentPolicy } from "./policies/attachment-policy" import { BotPolicy } from "./policies/bot-policy" +import { InvitationPolicy } from "./policies/invitation-policy" +import { Invitations } from "./services/invitations" import { ChannelMemberPolicy } from "./policies/channel-member-policy" import { ChannelPolicy } from "./policies/channel-policy" import { ChannelSectionPolicy } from "./policies/channel-section-policy" @@ -169,6 +172,7 @@ export const RepoLive = Layer.mergeAll( BotCommandRepo.layer, BotInstallationRepo.layer, CustomEmojiRepo.layer, + InvitationRepo.layer, ) export const PolicyLive = Layer.mergeAll( @@ -192,6 +196,7 @@ export const PolicyLive = Layer.mergeAll( RssSubscriptionPolicy.layer, BotPolicy.layer, CustomEmojiPolicy.layer, + InvitationPolicy.layer, ) /** @@ -217,6 +222,7 @@ export const AppServicesLive = Layer.mergeAll( WebhookBotService.layer, ChannelAccessSyncService.layer, ConnectConversationService.layer, + Invitations.layer, // SessionManager.layer includes BackendAuth.layer via dependencies SessionManager.layer, OAuthBearerAuth.layer, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 8f6b456a3..66dc2f475 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -9,6 +9,7 @@ import { createTracingLayer } from "@hazel/effect-bun/Telemetry" import { Config, ConfigProvider, Effect, Layer } from "effect" import { HttpMiddleware, HttpRouter } from "effect/http" import { AllRoutes, AppAuthorizationLive, AppServicesLive, HazelApi } from "./app" +import { InviteMailer } from "./services/invite-mailer" import { BotGatewayTransport } from "./services/bot-gateway-transport" import { DiscordGatewayBackgroundLive } from "./services/chat-sync/discord-gateway-service" import { EnvVars } from "./lib/env-vars" @@ -42,6 +43,8 @@ const PlatformLive = Layer.mergeAll( Redis.Default, RateLimiterRedisLive, BotGatewayTransport.layerDurableStreams, + // No email service on Bun: invite links are logged. + InviteMailer.layerLog, ) /** Long-running loops; on Cloudflare these are Durable Objects driven by crons. */ diff --git a/apps/backend/src/policies/invitation-policy.ts b/apps/backend/src/policies/invitation-policy.ts new file mode 100644 index 000000000..dd0ca1c0f --- /dev/null +++ b/apps/backend/src/policies/invitation-policy.ts @@ -0,0 +1,27 @@ +import { ErrorUtils } from "@hazel/domain" +import type { OrganizationId } from "@hazel/schema" +import { Context, Effect, Layer } from "effect" +import { withAnnotatedScope } from "../lib/policy-utils" +import { OrgResolver } from "../services/org-resolver" + +/** Only an organization's admins and owners invite, list and revoke its invitations. */ +export class InvitationPolicy extends Context.Service()("InvitationPolicy/Policy", { + make: Effect.gen(function* () { + const policyEntity = "Invitation" as const + const orgResolver = yield* OrgResolver + + const canManage = (organizationId: OrganizationId, action: "create" | "select" | "delete") => + ErrorUtils.refailUnauthorized( + policyEntity, + action, + )( + withAnnotatedScope((scope) => + orgResolver.requireAdminOrOwner(organizationId, scope, policyEntity, action), + ), + ) + + return { canManage } as const + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgResolver.layer)) +} diff --git a/apps/backend/src/rpc/handlers/invitations.ts b/apps/backend/src/rpc/handlers/invitations.ts new file mode 100644 index 000000000..23f475314 --- /dev/null +++ b/apps/backend/src/rpc/handlers/invitations.ts @@ -0,0 +1,16 @@ +import { InvitationRpcs } from "@hazel/domain/rpc" +import { Effect } from "effect" +import { Invitations } from "../../services/invitations" + +export const InvitationRpcLive = InvitationRpcs.toLayer( + Effect.gen(function* () { + const invitations = yield* Invitations + return { + "invitation.create": invitations.create, + "invitation.list": invitations.list, + "invitation.revoke": invitations.revoke, + "invitation.preview": invitations.preview, + "invitation.accept": invitations.accept, + } + }), +) diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index dfcc7e8a5..df105594f 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -1,11 +1,9 @@ import type { OrganizationId } from "@hazel/schema" -import { ClerkClient } from "@hazel/auth" import { ChannelMemberRepo, ChannelRepo, OrganizationMemberRepo, OrganizationRepo, - UserRepo, } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" @@ -117,15 +115,12 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( const channelMemberRepo = yield* ChannelMemberRepo const organizationMemberRepo = yield* OrganizationMemberRepo const channelAccessSync = yield* ChannelAccessSyncService - const clerkClient = yield* ClerkClient - const userRepo = yield* UserRepo return { "organization.create": (payload) => Effect.gen(function* () { const currentUser = yield* CurrentUser.Context - // Fail fast on a locally-taken slug before creating anything in Clerk. if (payload.slug) { const existingOrganization = yield* organizationRepo.findBySlug(payload.slug) @@ -141,118 +136,47 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( yield* organizationPolicy.canCreate() - // The creator must become the Clerk org admin, so resolve their Clerk user id. - const userOption = yield* userRepo.findById(currentUser.id) - if (Option.isNone(userOption)) { - return yield* Effect.fail( - new InternalServerError({ - message: "Error creating Organization", - detail: "Could not resolve the current user for Clerk organization creation", - }), - ) - } - - // Create the org in Clerk first — Clerk is the source of truth for org - // membership and powers Clerk-backed features (invitations, member management). - // The inbound webhook later dedupes back to our row via clerkOrganizationId. - const clerkOrg = yield* clerkClient - .createOrganization({ - name: payload.name, - slug: payload.slug, - createdBy: userOption.value.externalId, - }) - .pipe( - Effect.catchTag( - "OrganizationCreateError", - ( - err, - ): Effect.Effect< - never, - OrganizationSlugAlreadyExistsError | InternalServerError - > => { - const detail = err.detail ?? "" - // Clerk rejects duplicate slugs — surface as the slug-taken error. - if ( - payload.slug && - /slug/i.test(detail) && - /(taken|already|exist|unique|duplicate)/i.test(detail) - ) { - return Effect.fail( - new OrganizationSlugAlreadyExistsError({ - message: `Organization slug '${payload.slug}' is already taken`, - slug: payload.slug, - }), - ) - } - return Effect.fail( - new InternalServerError({ - message: "Error creating Organization", - detail: "Failed to create the organization in Clerk", - cause: detail, - }), - ) - }, - ), - ) - - // Run local setup in a transaction. If it fails, delete the Clerk org we just - // created so the two systems don't drift. - return yield* db - .transaction( - Effect.gen(function* () { - const createdOrganization = yield* organizationRepo - .insert({ - name: payload.name, - slug: payload.slug, - logoUrl: payload.logoUrl, - settings: { - ...(payload.settings ?? {}), - clerkOrganizationId: clerkOrg.id, - }, - isPublic: false, - deletedAt: null, - }) - .pipe(Effect.map((res) => res[0]!)) - - yield* organizationMemberRepo.upsertByOrgAndUser({ - organizationId: createdOrganization.id, - userId: currentUser.id, - role: "owner", - nickname: undefined, - joinedAt: new Date(), - invitedBy: null, + return yield* db.transaction( + Effect.gen(function* () { + const createdOrganization = yield* organizationRepo + .insert({ + name: payload.name, + slug: payload.slug, + logoUrl: payload.logoUrl, + settings: payload.settings ?? null, + isPublic: false, deletedAt: null, }) + .pipe(Effect.map((res) => res[0]!)) - // Setup default channels for the organization - yield* organizationRepo.setupDefaultChannels( - createdOrganization.id, - currentUser.id, - ) - - yield* channelAccessSync.syncUserInOrganization( - currentUser.id, - createdOrganization.id, - ) - - const txid = yield* generateTransactionId() - - return new OrganizationResponse({ - data: { - ...createdOrganization, - settings: createdOrganization.settings as { - readonly [x: string]: unknown - } | null, - }, - transactionId: txid, - }) - }), - ) - .pipe( - Effect.onError(() => - clerkClient.deleteOrganization(clerkOrg.id).pipe(Effect.ignore), - ), - ) + yield* organizationMemberRepo.upsertByOrgAndUser({ + organizationId: createdOrganization.id, + userId: currentUser.id, + role: "owner", + nickname: undefined, + joinedAt: new Date(), + invitedBy: null, + deletedAt: null, + }) + + // Setup default channels for the organization + yield* organizationRepo.setupDefaultChannels(createdOrganization.id, currentUser.id) + + yield* channelAccessSync.syncUserInOrganization(currentUser.id, createdOrganization.id) + + const txid = yield* generateTransactionId() + + return new OrganizationResponse({ + data: { + ...createdOrganization, + settings: createdOrganization.settings as { + readonly [x: string]: unknown + } | null, + }, + transactionId: txid, + }) + }), + ) }).pipe(handleOrganizationDbErrors("Organization", "create")), "organization.update": ({ id, ...payload }) => diff --git a/apps/backend/src/rpc/server.ts b/apps/backend/src/rpc/server.ts index 84a0a6b40..f0ff4b069 100644 --- a/apps/backend/src/rpc/server.ts +++ b/apps/backend/src/rpc/server.ts @@ -9,6 +9,7 @@ import { ChannelWebhookRpcs, ConnectShareRpcs, CustomEmojiRpcs, + InvitationRpcs, GitHubSubscriptionRpcs, IntegrationRequestRpcs, RssSubscriptionRpcs, @@ -33,6 +34,7 @@ import { ChannelWebhookRpcLive } from "./handlers/channel-webhooks" import { ChannelRpcLive } from "./handlers/channels" import { ConnectShareRpcLive } from "./handlers/connect-shares" import { CustomEmojiRpcLive } from "./handlers/custom-emojis" +import { InvitationRpcLive } from "./handlers/invitations" import { GitHubSubscriptionRpcLive } from "./handlers/github-subscriptions" import { IntegrationRequestRpcLive } from "./handlers/integration-requests" import { RssSubscriptionRpcLive } from "./handlers/rss-subscriptions" @@ -83,6 +85,7 @@ const BaseRpcs = MessageRpcs.merge( AttachmentRpcs, BotRpcs, CustomEmojiRpcs, + InvitationRpcs, ) export const AllRpcs = BaseRpcs.merge(ChatSyncRpcs).middleware(ScopeInjectionMiddleware) @@ -109,6 +112,7 @@ const rpcGroups = [ ["AttachmentRpcs", AttachmentRpcs], ["BotRpcs", BotRpcs], ["CustomEmojiRpcs", CustomEmojiRpcs], + ["InvitationRpcs", InvitationRpcs], ["ChatSyncRpcs", ChatSyncRpcs], ] as const @@ -146,5 +150,6 @@ export const RpcServerLive = Layer.empty Layer.provideMerge(AttachmentRpcLive), Layer.provideMerge(BotRpcLive), Layer.provideMerge(CustomEmojiRpcLive), + Layer.provideMerge(InvitationRpcLive), ) .pipe(Layer.provideMerge(AuthMiddlewareLive), Layer.provideMerge(ScopeInjectionMiddlewareLive)) diff --git a/apps/backend/src/services/invitations.test.ts b/apps/backend/src/services/invitations.test.ts new file mode 100644 index 000000000..d83a1ef86 --- /dev/null +++ b/apps/backend/src/services/invitations.test.ts @@ -0,0 +1,205 @@ +/** + * Email invitations end to end against a disposable Postgres: inviting (admins only, members + * skipped, one email per address), the public preview, accepting with the invited account, and + * revoking. Emails are captured instead of sent. + */ +import { Database } from "@hazel/db" +import { CurrentUser } from "@hazel/domain" +import { CurrentRpcScopes } from "@hazel/domain/scopes" +import type { ApiScope } from "@hazel/domain/scopes" +import { OrganizationId, UserId } from "@hazel/schema" +import { ConfigProvider, Effect, Exit, Layer, Schema } from "effect" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" +import { type InviteEmail, InviteMailer } from "./invite-mailer" +import { Invitations } from "./invitations" + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` +const orgId = Schema.decodeSync(OrganizationId)(id(1)) +const owner = Schema.decodeSync(UserId)(id(10)) +const member = Schema.decodeSync(UserId)(id(11)) +const invitee = Schema.decodeSync(UserId)(id(12)) + +const users = { + [owner]: "owner@hazel.test", + [member]: "member@hazel.test", + [invitee]: "Invitee@Hazel.test", +} as const + +const actor = (userId: UserId) => + new CurrentUser.Schema({ + id: userId, + role: "member", + organizationId: undefined, + avatarUrl: undefined, + firstName: "Test", + lastName: "User", + email: users[userId as keyof typeof users], + isOnboarded: true, + timezone: null, + settings: null, + }) + +let harness: ChatSyncDbHarness +const sent: Array = [] + +const CaptureMailer = Layer.succeed(InviteMailer, { + send: (invite) => Effect.sync(() => void sent.push(invite)), +}) + +/** Runs as `userId` with the RPC's annotated scope, like a request through the RPC middleware. */ +const run = ( + userId: UserId, + scope: ApiScope, + effect: (invitations: typeof Invitations.Service) => Effect.Effect, +) => + harness.run( + Effect.gen(function* () { + const invitations = yield* Invitations + return yield* Effect.exit(effect(invitations)) + }).pipe( + Effect.provideService(CurrentUser.Context, actor(userId)), + Effect.provideService(CurrentRpcScopes, [scope]), + Effect.provide(Invitations.layer), + Effect.provide(CaptureMailer), + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ FRONTEND_URL: "https://app.hazel.test" })), + ), + ), + ) + +const tokenOf = (invite: InviteEmail) => new URL(invite.acceptUrl).pathname.split("/").at(-1)! + +const failureTag = (exit: Exit.Exit) => + Exit.isFailure(exit) + ? exit.cause.reasons.map((reason) => + "error" in reason && + typeof reason.error === "object" && + reason.error !== null && + "_tag" in reason.error + ? String(reason.error._tag) + : reason._tag, + ) + : [] + +describe("invitations", () => { + beforeAll(async () => { + harness = await createChatSyncDbHarness() + await harness.reset() + await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + yield* db.execute((client) => + client.$client.unsafe(` + INSERT INTO users (id, "externalId", email, "firstName", "lastName", "userType", "isOnboarded", "createdAt", "updatedAt") + VALUES + ('${owner}', 'owner', '${users[owner]}', 'Olive', 'Owner', 'user', true, NOW(), NOW()), + ('${member}', 'member', '${users[member]}', 'Max', 'Member', 'user', true, NOW(), NOW()), + ('${invitee}', 'invitee', '${users[invitee]}', 'Ivy', 'Invitee', 'user', true, NOW(), NOW()); + INSERT INTO organizations (id, name, slug, "isPublic", "createdAt", "updatedAt") + VALUES ('${orgId}', 'Hazel Labs', 'hazel-labs', false, NOW(), NOW()); + INSERT INTO organization_members (id, "organizationId", "userId", role, "joinedAt", "createdAt") + VALUES + ('${id(20)}', '${orgId}', '${owner}', 'owner', NOW(), NOW()), + ('${id(21)}', '${orgId}', '${member}', 'member', NOW(), NOW()); + `), + ) + }), + ) + }, 120_000) + + afterAll(async () => { + await harness?.stop() + }) + + it("only admins and owners invite", async () => { + const exit = await run(member, "organization-members:write", (invitations) => + invitations.create({ organizationId: orgId, emails: ["new@hazel.test"], role: "member" }), + ) + expect(failureTag(exit)).toEqual(["UnauthorizedError"]) + }) + + it("invites new addresses, skips members, and the invited account accepts", async () => { + sent.length = 0 + const created = await run(owner, "organization-members:write", (invitations) => + invitations.create({ + organizationId: orgId, + emails: ["invitee@hazel.test", "MEMBER@hazel.test"], + role: "admin", + }), + ) + expect(Exit.isSuccess(created) && created.value.invited.map((invite) => invite.email)).toEqual([ + "invitee@hazel.test", + ]) + expect(Exit.isSuccess(created) && created.value.skipped).toEqual([ + { email: "member@hazel.test", reason: "AlreadyMember" }, + ]) + expect(sent).toHaveLength(1) + expect(sent[0]).toMatchObject({ to: "invitee@hazel.test", organizationName: "Hazel Labs" }) + expect(sent[0]!.acceptUrl).toMatch(/^https:\/\/app\.hazel\.test\/invite\//) + const token = tokenOf(sent[0]!) + + const listed = await run(owner, "organization-members:read", (invitations) => + invitations.list({ organizationId: orgId }), + ) + expect(Exit.isSuccess(listed) && listed.value.map((invite) => invite.role)).toEqual(["admin"]) + + const preview = await run(member, "organizations:read", (invitations) => + invitations.preview({ token }), + ) + expect(Exit.isSuccess(preview) && preview.value.status).toBe("Pending") + + const wrongAccount = await run(member, "organizations:write", (invitations) => + invitations.accept({ token }), + ) + expect(failureTag(wrongAccount)).toEqual(["InvitationEmailMismatchError"]) + + const accepted = await run(invitee, "organizations:write", (invitations) => + invitations.accept({ token }), + ) + expect(Exit.isSuccess(accepted) && accepted.value.slug).toBe("hazel-labs") + const role = await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.execute((client) => + client.$client.unsafe( + `select role from organization_members where "organizationId" = '${orgId}' and "userId" = '${invitee}'`, + ), + ) + }), + ) + expect(role).toEqual([{ role: "admin" }]) + + const again = await run(invitee, "organizations:write", (invitations) => + invitations.accept({ token }), + ) + expect(failureTag(again)).toEqual(["InvitationUnavailableError"]) + }, 60_000) + + it("re-inviting replaces the link, and a revoked link stops working", async () => { + sent.length = 0 + const invite = () => + run(owner, "organization-members:write", (invitations) => + invitations.create({ organizationId: orgId, emails: ["late@hazel.test"], role: "member" }), + ) + await invite() + const second = await invite() + const [first, latest] = [tokenOf(sent[0]!), tokenOf(sent[1]!)] + expect(first).not.toBe(latest) + + const stale = await run(member, "organizations:read", (invitations) => + invitations.preview({ token: first }), + ) + expect(failureTag(stale)).toEqual(["InvitationNotFoundError"]) + + const invitationId = Exit.isSuccess(second) ? second.value.invited[0]!.id : undefined + const revoked = await run(owner, "organization-members:write", (invitations) => + invitations.revoke({ id: invitationId! }), + ) + expect(Exit.isSuccess(revoked)).toBe(true) + const preview = await run(member, "organizations:read", (invitations) => + invitations.preview({ token: latest }), + ) + expect(Exit.isSuccess(preview) && preview.value.status).toBe("Revoked") + }, 60_000) +}) diff --git a/apps/backend/src/services/invitations.ts b/apps/backend/src/services/invitations.ts new file mode 100644 index 000000000..f03d191f1 --- /dev/null +++ b/apps/backend/src/services/invitations.ts @@ -0,0 +1,273 @@ +import { + ChannelMemberRepo, + ChannelRepo, + InvitationRepo, + OrganizationMemberRepo, + OrganizationRepo, +} from "@hazel/backend-core" +import { Database, type schema } from "@hazel/db" +import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" +import { + Invitation, + InvitationAccepted, + InvitationCreateResult, + InvitationEmailMismatchError, + InvitationNotFoundError, + InvitationPreview, + type InvitationRole, + type InvitationSkip, + InvitationUnavailableError, + OrganizationNotFoundError, +} from "@hazel/domain/rpc" +import type { InvitationId, OrganizationId } from "@hazel/schema" +import { Config, Context, DateTime, Effect, Layer, Option } from "effect" +import { Base64Url, Hex } from "effect/encoding" +import { InvitationPolicy } from "../policies/invitation-policy" +import { ChannelAccessSyncService } from "./channel-access-sync" +import { InviteMailer } from "./invite-mailer" + +/** How long an invite link works. */ +const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000 + +type InvitationRow = typeof schema.organizationInvitationsTable.$inferSelect + +const newToken = () => Base64Url.encode(crypto.getRandomValues(new Uint8Array(32))) + +/** Only the hash is stored, so a database read can't produce working links. */ +const hashToken = (token: string) => + Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))).pipe( + Effect.map((digest) => Hex.encode(new Uint8Array(digest))), + ) + +const toInvitation = (row: InvitationRow) => + new Invitation({ + id: row.id, + organizationId: row.organizationId, + email: row.email, + role: row.role === "admin" ? "admin" : "member", + invitedBy: row.invitedBy, + createdAt: DateTime.fromDateUnsafe(row.createdAt), + expiresAt: DateTime.fromDateUnsafe(row.expiresAt), + }) + +const statusOf = (row: InvitationRow, now: Date) => + row.status === "accepted" + ? ("Accepted" as const) + : row.status === "revoked" + ? ("Revoked" as const) + : row.expiresAt <= now + ? ("Expired" as const) + : ("Pending" as const) + +/** + * Organization email invitations: invite by email, list and revoke (admins and owners), and the + * public link's preview and accept. The `invitation.*` RPCs delegate here. + */ +export class Invitations extends Context.Service()("Invitations", { + make: Effect.gen(function* () { + const db = yield* Database.Database + const policy = yield* InvitationPolicy + const invitationRepo = yield* InvitationRepo + const organizationRepo = yield* OrganizationRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const channelRepo = yield* ChannelRepo + const channelMemberRepo = yield* ChannelMemberRepo + const channelAccessSync = yield* ChannelAccessSyncService + const mailer = yield* InviteMailer + const frontendUrl = yield* Config.String("FRONTEND_URL") + + const findByToken = (token: string) => + hashToken(token).pipe( + Effect.flatMap(invitationRepo.findByTokenHash), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new InvitationNotFoundError()), + onSome: Effect.succeed, + }), + ), + ) + + const findOrganization = (organizationId: InvitationRow["organizationId"]) => + organizationRepo + .findById(organizationId) + .pipe( + Effect.flatMap((organization) => + Option.isSome(organization) && organization.value.deletedAt === null + ? Effect.succeed(organization.value) + : Effect.fail(new OrganizationNotFoundError({ organizationId })), + ), + ) + + return { + create: ({ + organizationId, + emails, + role, + }: { + readonly organizationId: OrganizationId + readonly emails: ReadonlyArray + readonly role: InvitationRole + }) => + Effect.gen(function* () { + yield* policy.canManage(organizationId, "create") + const currentUser = yield* CurrentUser.Context + const organization = yield* findOrganization(organizationId).pipe( + Effect.catchTag("OrganizationNotFoundError", () => + Effect.fail(new InternalServerError({ message: "Organization not found" })), + ), + ) + const inviterName = + `${currentUser.firstName} ${currentUser.lastName}`.trim() || currentUser.email + + const addresses = [...new Set(emails.map((email) => email.toLowerCase()))] + const members = yield* invitationRepo.findMemberEmails(organizationId, addresses) + + const invited: Array = [] + const skipped: Array = addresses + .filter((email) => members.has(email)) + .map((email) => ({ email, reason: "AlreadyMember" as const })) + + for (const email of addresses.filter((address) => !members.has(address))) { + const token = newToken() + const row = yield* invitationRepo.upsertPending({ + organizationId, + email, + role, + tokenHash: yield* hashToken(token), + invitedBy: currentUser.id, + expiresAt: new Date(Date.now() + INVITATION_LIFETIME_MS), + }) + const sent = yield* mailer + .send({ + to: email, + organizationName: organization.name, + inviterName, + acceptUrl: new URL(`/invite/${token}`, frontendUrl).href, + }) + .pipe(Effect.result) + if (sent._tag === "Success") { + invited.push(toInvitation(row)) + } else { + // A link nobody received shouldn't sit in the pending list. + yield* Effect.logWarning("[invite] email failed", { error: sent.failure.message }) + yield* invitationRepo.revoke(row.id) + skipped.push({ email, reason: "EmailFailed" }) + } + } + + return new InvitationCreateResult({ invited, skipped }) + }).pipe(withRemapDbErrors("Invitation", "create")), + + list: ({ organizationId }: { readonly organizationId: OrganizationId }) => + Effect.gen(function* () { + yield* policy.canManage(organizationId, "select") + const rows = yield* invitationRepo.listPending(organizationId, new Date()) + return rows.map(toInvitation) + }).pipe(withRemapDbErrors("Invitation", "select")), + + revoke: ({ id }: { readonly id: InvitationId }) => + Effect.gen(function* () { + const invitation = yield* invitationRepo.findById(id) + if (Option.isNone(invitation)) return yield* new InvitationNotFoundError() + yield* policy.canManage(invitation.value.organizationId, "delete") + yield* invitationRepo.revoke(id) + }).pipe(withRemapDbErrors("Invitation", "delete")), + + preview: ({ token }: { readonly token: string }) => + Effect.gen(function* () { + const invitation = yield* findByToken(token) + const organization = yield* findOrganization(invitation.organizationId).pipe( + Effect.catchTag("OrganizationNotFoundError", () => + Effect.fail(new InvitationNotFoundError()), + ), + ) + return new InvitationPreview({ + organizationName: organization.name, + organizationLogoUrl: organization.logoUrl ?? null, + email: invitation.email, + status: statusOf(invitation, new Date()), + }) + }).pipe(withRemapDbErrors("Invitation", "select")), + + accept: ({ token }: { readonly token: string }) => + db + .transaction( + Effect.gen(function* () { + const currentUser = yield* CurrentUser.Context + const invitation = yield* findByToken(token) + const status = statusOf(invitation, new Date()) + if (status !== "Pending") return yield* new InvitationUnavailableError({ status }) + if (currentUser.email.toLowerCase() !== invitation.email) + return yield* new InvitationEmailMismatchError({ + invitedEmail: invitation.email, + }) + + const organization = yield* findOrganization(invitation.organizationId) + const accepted = yield* invitationRepo.markAccepted(invitation.id, currentUser.id) + if (Option.isNone(accepted)) + return yield* new InvitationUnavailableError({ status: "Accepted" }) + + const existing = yield* organizationMemberRepo.findByOrgAndUser( + organization.id, + currentUser.id, + ) + if (Option.isNone(existing) || existing.value.deletedAt !== null) { + yield* organizationMemberRepo.upsertByOrgAndUser({ + organizationId: organization.id, + userId: currentUser.id, + role: invitation.role, + nickname: undefined, + joinedAt: new Date(), + invitedBy: invitation.invitedBy, + deletedAt: null, + }) + // Like joining by public link: into #general. + const general = yield* channelRepo.findByOrgAndName( + organization.id, + "general", + ) + if (Option.isSome(general)) { + const membership = yield* channelMemberRepo.findByChannelAndUser( + general.value.id, + currentUser.id, + ) + if (Option.isNone(membership)) + yield* channelMemberRepo.insert({ + channelId: general.value.id, + userId: currentUser.id, + isHidden: false, + isMuted: false, + isFavorite: false, + lastSeenMessageId: null, + notificationCount: 0, + joinedAt: new Date(), + deletedAt: null, + }) + } + yield* channelAccessSync.syncUserInOrganization( + currentUser.id, + organization.id, + ) + } + + return new InvitationAccepted({ + organizationId: organization.id, + slug: organization.slug ?? null, + }) + }), + ) + .pipe(withRemapDbErrors("Invitation", "update")), + } + }), +}) { + /** Needs `Database`, `InviteMailer` and `FRONTEND_URL` from the platform. */ + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(InvitationPolicy.layer), + Layer.provide(ChannelAccessSyncService.layer), + Layer.provide(InvitationRepo.layer), + Layer.provide(OrganizationRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(ChannelMemberRepo.layer), + ) +} diff --git a/apps/backend/src/services/invite-mailer.test.ts b/apps/backend/src/services/invite-mailer.test.ts new file mode 100644 index 000000000..cf5800a25 --- /dev/null +++ b/apps/backend/src/services/invite-mailer.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" +import { renderInviteEmail } from "./invite-mailer" + +describe("renderInviteEmail", () => { + it("escapes names in the HTML and keeps the plain text readable", () => { + const email = renderInviteEmail({ + to: "ada@hazel.test", + organizationName: ` & Co`, + inviterName: "Grace", + acceptUrl: "https://app.hazel.sh/invite/abc", + }) + expect(email.subject).toBe(`Grace invited you to & Co on Hazel`) + expect(email.html).not.toContain("