diff --git a/bun.lock b/bun.lock index 6cb30b2..7cf8d40 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "p0dcasters", "dependencies": { "@logicsrc/openaccess": "0.3.1", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "0.3.0", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/openprofile": "^0.2.0", @@ -128,6 +129,8 @@ "@logicsrc/openaccess": ["@logicsrc/openaccess@0.3.1", "", { "dependencies": { "@hono/node-server": "^2.1.1", "@profullstack/footer": "^0.2.6", "hono": "^4.13.7" }, "bin": { "openaccess": "bin/openaccess.mjs" } }, "sha512-J92zpd53IlPR1a4ZwC/tg0zSc2WVHCkP2DwO0L0/YRIujGqaxP9eJizauzqdnsEIsGs9GLPRViWBgUH93jDXKw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.2", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], @@ -192,6 +195,8 @@ "@peculiar/x509": ["@peculiar/x509@2.2.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-csr": "^2.11.0", "@peculiar/asn1-ecc": "^2.11.0", "@peculiar/asn1-pkcs9": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-post-quantum": "^2.11.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/footer": ["@profullstack/footer@0.3.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-T95BxnTLRWj0OdEZQwGQkHJCPSBs8VpDATyAIPUqomnC9IRMMvgLEFeION1HSqIvwgHgiC0vbs15ydZyFq99ew=="], "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="], @@ -266,22 +271,48 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], "asn1js": ["asn1js@3.1.0", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.2.0", "tslib": "^2.8.1" } }, "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], + "bullmq": ["bullmq@6.4.2", "", { "dependencies": { "cron-parser": "5.10.2", "msgpackr": "2.1.2", "node-abort-controller": "3.1.1", "semver": "7.8.5", "tslib": "2.8.1" }, "peerDependencies": { "bullmq-otel": ">=2.0.0", "ioredis": ">=5.0.0", "pg": ">=8.0.0", "redis": ">=5.0.0" }, "optionalPeers": ["bullmq-otel", "ioredis", "pg", "redis"] }, "sha512-fCPPJUgJFwbaN7nZiU/JzQVydss+mA3FUYaepvJQnkSUr4jmzobZcqMSJZPZygN/esQZLppE/cTyTYTNniO3bQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001819", "", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], + "content-disposition": ["content-disposition@2.0.1", "", {}, "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cron-parser": ["cron-parser@5.10.2", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -290,34 +321,112 @@ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.1", "", {}, "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.3.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.3.0", "content-disposition": "^2.0.1", "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.8", "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.1.0", "vary": "^1.1.2" } }, "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g=="], + + "express-rate-limit": ["express-rate-limit@8.7.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hls.js": ["hls.js@1.7.3", "", {}, "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A=="], "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ioredis": ["ioredis@6.0.0", "", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="], + "ip-address": ["ip-address@10.7.5", "", {}, "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.13", "", {}, "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ=="], + "js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mpegts.js": ["mpegts.js@1.8.2", "", { "dependencies": { "es6-promise": "^4.2.8", "events": "^3.3.0" } }, "sha512-cZYMa5muASH55wrS6JK5IGkUqnCA96c0f+WdnW91jIK9s/N6NzjmTQDJNYg9A5u/G2Q5nj2b3w1Zwzze/9guxA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -328,6 +437,8 @@ "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="], "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], @@ -338,8 +449,22 @@ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.2", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], @@ -358,6 +483,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -370,10 +497,18 @@ "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qs": ["qs@6.17.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], @@ -382,18 +517,44 @@ "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "strnum": ["strnum@2.5.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -402,26 +563,50 @@ "tldts-core": ["tldts-core@7.4.20", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "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=="], "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@logicsrc/openaccess/@profullstack/footer": ["@profullstack/footer@0.2.6", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-BKwbDszAc7e0mYIBiPjgr/5WS8WyDt8IW718s/zW/8pCWOIB1BGwSNs6fmImRSo5kqIjQKUVbXp22jZQ5GEipA=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "express/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], } } diff --git a/db/schema.pg.sql b/db/schema.pg.sql index 9dc0bbd..4f55aa4 100644 --- a/db/schema.pg.sql +++ b/db/schema.pg.sql @@ -239,3 +239,16 @@ create index if not exists i_host on podcasts(host); create index if not exists i_score on podcasts(score desc); create index if not exists i_new on podcasts(newest_pubdate desc); create index if not exists i_search on podcasts using gin (p0d_search_vector(title, host, author, description)); + +-- Sign in with CoinPay (src/lib/auth/coinpay.ts). Bound by the CoinPay `sub` +-- only, never by email; coinpay_email is unverified contact info. Also created +-- lazily by src/lib/auth/coinpay-store.ts. +create table if not exists coinpay_identities ( + coinpay_sub text primary key, + user_id bigint not null unique references users(id) on delete cascade, + coinpay_email text, + coinpay_name text, + created_via text not null, + created_at bigint not null, + last_login_at bigint +); diff --git a/package.json b/package.json index 3c84ec0..816cbce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p0dcasters", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { @@ -13,6 +13,7 @@ }, "dependencies": { "@logicsrc/openaccess": "0.3.1", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "0.3.0", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/openprofile": "^0.2.0", diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 980b1f4..cf2a0d7 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -13,6 +13,8 @@ import { COLUMNS } from "@profullstack/profile"; import { profileStore } from "@/lib/profile"; import Ad from "@/components/Ad"; import { AD_TEXT } from "@/lib/ads"; +import { canDisconnect, coinpayConfig, coinpayMessage, isPlaceholderEmail } from "@/lib/auth/coinpay"; +import { dbCoinpayStore } from "@/lib/auth/coinpay-store"; export const dynamic = "force-dynamic"; @@ -21,28 +23,38 @@ export const metadata: Metadata = { robots: { index: false }, }; -export default async function Account() { +export default async function Account({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; const user = await currentUser(); if (!user) redirect("/login?next=/account"); - const [passkeys, follows, mine, digest] = await Promise.all([ + const [passkeys, follows, mine, digest, coinpay] = await Promise.all([ credentialsFor(user.id), count("SELECT COUNT(*) n FROM follows WHERE user_id = ?", [user.id]), // A missing table must not take the account page down with it. profileStore().get(user.id).catch(() => null), isSubscribed(user.id).catch(() => true), + dbCoinpayStore().identityByUser(user.id).catch(() => null), ]); + const placeholder = isPlaceholderEmail(user.email); + const coinpayNote = coinpayMessage(params.coinpay); // Only the six fields cross to the client form, as plain strings. const profileValues: Record = Object.fromEntries( COLUMNS.map((c) => [c, mine?.[c] == null ? null : String(mine[c])]), ); - if (!profileValues.email) profileValues.email = user.email; + // A CoinPay-made account has an undeliverable placeholder address; never + // prefill it (nor the unverified CoinPay email) as the reader's. + if (!profileValues.email && !placeholder) profileValues.email = user.email; return (

Your account

- Signed in as {user.email}. Joined{" "} + Signed in {placeholder ? <>with CoinPay : <>as {user.email}}. Joined{" "} {new Date(user.created_at * 1000).toLocaleDateString(undefined, { year: "numeric", month: "long", @@ -92,6 +104,45 @@ export default async function Account() {

+
+

CoinPay

+ {params.coinpay === "connected" &&

CoinPay is connected.

} + {params.coinpay === "disconnected" &&

CoinPay is disconnected.

} + {coinpayNote &&

{coinpayNote}

} + {coinpay ? ( + <> +

+ Connected: you can sign in here with your CoinPay account + {coinpay.coinpay_email ? <> ({coinpay.coinpay_email}) : null}. +

+ {canDisconnect(user, passkeys.length) ? ( +
+ +
+ ) : ( +

+ CoinPay is the only way into this account. Add a passkey below before + disconnecting it. +

+ )} + + ) : coinpayConfig() ? ( + <> +

+ Connect your CoinPay account to sign in here with it, the same account you + use across the Profullstack sites. +

+ + Connect CoinPay + + + ) : ( +

Connecting CoinPay is not available right now.

+ )} +
+

Passkeys

diff --git a/src/app/api/v1/coinpay/disconnect/route.ts b/src/app/api/v1/coinpay/disconnect/route.ts new file mode 100644 index 0000000..a2be50d --- /dev/null +++ b/src/app/api/v1/coinpay/disconnect/route.ts @@ -0,0 +1,20 @@ +import { currentUser } from "@/lib/auth/session"; +import { credentialsFor } from "@/lib/auth/passkey"; +import { canDisconnect } from "@/lib/auth/coinpay"; +import { dbCoinpayStore } from "@/lib/auth/coinpay-store"; + +export const dynamic = "force-dynamic"; + +const back = (path: string) => new Response(null, { status: 303, headers: { location: path } }); + +/** POST from /account. The session cookie is SameSite=Lax, so a cross-site form cannot drive it. */ +export async function POST() { + const user = await currentUser(); + if (!user) return back("/login?next=/account"); + const store = dbCoinpayStore(); + if (!(await store.identityByUser(user.id))) return back("/account"); + const passkeys = await credentialsFor(user.id); + if (!canDisconnect(user, passkeys.length)) return back("/account?coinpay=only_way_in"); + await store.unlink(user.id); + return back("/account?coinpay=disconnected"); +} diff --git a/src/app/api/v1/coinpay/oauth/callback/route.ts b/src/app/api/v1/coinpay/oauth/callback/route.ts new file mode 100644 index 0000000..99e76aa --- /dev/null +++ b/src/app/api/v1/coinpay/oauth/callback/route.ts @@ -0,0 +1,66 @@ +import { cookies } from "next/headers"; +import { finishSignIn, sameState } from "@profullstack/coinpay-onboard/oauth"; +import { currentUser, startSession } from "@/lib/auth/session"; +import { + PENDING_COOKIE, + PENDING_COOKIE_PATH, + claimsFrom, + coinpayConfig, + decodePending, + redirectUri, + resolveCoinpayUser, +} from "@/lib/auth/coinpay"; +import { dbCoinpayStore } from "@/lib/auth/coinpay-store"; + +export const dynamic = "force-dynamic"; + +const back = (path: string) => + new Response(null, { status: 302, headers: { location: path, "cache-control": "no-store" } }); + +/** CoinPay redirects here with ?code&state (or ?error). */ +export async function GET(req: Request) { + const params = new URL(req.url).searchParams; + const jar = await cookies(); + const pending = decodePending(jar.get(PENDING_COOKIE)?.value); + // Single use: the pending sign-in is spent whatever happens next. + jar.set(PENDING_COOKIE, "", { path: PENDING_COOKIE_PATH, maxAge: 0 }); + + const fail = (code: string) => + back(pending?.mode === "connect" ? `/account?coinpay=${code}` : `/login?coinpay=${code}`); + + const cfg = coinpayConfig(); + if (!cfg) return fail("unavailable"); + if (!pending) return fail("expired"); + if (!sameState(pending.state, params.get("state"))) return fail("state"); + if (params.get("error")) return fail("denied"); + const code = params.get("code"); + if (!code) return fail("denied"); + + let claims; + try { + const r = await finishSignIn({ + code, + verifier: pending.verifier, + nonce: pending.nonce, + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + redirectUri: redirectUri(cfg), + base: cfg.base, + }); + claims = claimsFrom(r); + } catch (err) { + console.error("coinpay sign-in failed:", err instanceof Error ? err.message : err); + return fail("failed"); + } + + const me = await currentUser(); + const result = await resolveCoinpayUser( + { mode: pending.mode, currentUserId: me?.id ?? null, claims }, + dbCoinpayStore(), + ); + if (!result.ok) return fail(result.code); + + if (pending.mode === "connect") return back("/account?coinpay=connected"); + await startSession(result.userId); + return back(pending.next || (result.action === "created" ? "/account" : "/following")); +} diff --git a/src/app/api/v1/coinpay/oauth/start/route.ts b/src/app/api/v1/coinpay/oauth/start/route.ts new file mode 100644 index 0000000..6e3de2a --- /dev/null +++ b/src/app/api/v1/coinpay/oauth/start/route.ts @@ -0,0 +1,40 @@ +import { cookies } from "next/headers"; +import { currentUser } from "@/lib/auth/session"; +import { safeNext } from "@/lib/auth/next"; +import { + PENDING_COOKIE, + PENDING_COOKIE_PATH, + PENDING_TTL, + beginSignIn, + coinpayConfig, + encodePending, +} from "@/lib/auth/coinpay"; + +export const dynamic = "force-dynamic"; + +// Relative Locations for our own pages: behind the proxy `req.url` is the +// internal bind address (see api/auth/logout). +const back = (path: string) => new Response(null, { status: 302, headers: { location: path } }); + +/** GET ?mode=login|connect&next=/path -> 302 to CoinPay's authorize page. */ +export async function GET(req: Request) { + const params = new URL(req.url).searchParams; + const mode = params.get("mode") === "connect" ? "connect" : "login"; + const next = safeNext(params.get("next")); + const cfg = coinpayConfig(); + if (!cfg) return back(mode === "connect" ? "/account?coinpay=unavailable" : "/login?coinpay=unavailable"); + // Connecting attaches CoinPay to the account you are in; there must be one. + if (mode === "connect" && !(await currentUser())) return back("/login?next=/account"); + + const { pending, url } = beginSignIn(cfg, mode, next); + const jar = await cookies(); + jar.set(PENDING_COOKIE, encodePending(pending), { + httpOnly: true, + // lax: CoinPay's redirect back is a top-level GET navigation, which lax allows. + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: PENDING_COOKIE_PATH, + maxAge: PENDING_TTL, + }); + return new Response(null, { status: 302, headers: { location: url, "cache-control": "no-store" } }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8313184..81f4fd7 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import AuthForm from "@/components/AuthForm"; import { currentUser } from "@/lib/auth/session"; import { safeNext } from "@/lib/auth/next"; +import { coinpayMessage } from "@/lib/auth/coinpay"; export const dynamic = "force-dynamic"; @@ -22,7 +23,7 @@ export default async function Login({ if (await currentUser()) redirect(next || "/following"); return (

- +
); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 7715ae6..c0eeda2 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import AuthForm from "@/components/AuthForm"; import { currentUser } from "@/lib/auth/session"; import { safeNext } from "@/lib/auth/next"; +import { coinpayMessage } from "@/lib/auth/coinpay"; export const dynamic = "force-dynamic"; @@ -24,7 +25,7 @@ export default async function Signup({ if (await currentUser()) redirect(next || "/following"); return (
- +
); } diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx index fec541b..502f8be 100644 --- a/src/components/AuthForm.tsx +++ b/src/components/AuthForm.tsx @@ -15,10 +15,13 @@ export default function AuthForm({ mode, next, expired, + coinpayError = null, }: { mode: "login" | "signup"; next: string; expired: boolean; + /** Why a CoinPay sign-in came back refused, already worded for a reader. */ + coinpayError?: string | null; }) { const [email, setEmail] = useState(""); const [sent, setSent] = useState(false); @@ -27,7 +30,7 @@ export default function AuthForm({ const [note, setNote] = useState( expired ? "That link had already been used, or it expired. Here is another." : null, ); - const [error, setError] = useState(null); + const [error, setError] = useState(coinpayError); const [suggestion, setSuggestion] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const formRef = useRef(null); @@ -161,6 +164,19 @@ export default function AuthForm({ {note && !sent &&

{note}

} + {/* CoinPay is the house master account: first, and a full navigation + (the start route sets the PKCE/state cookie and 302s to CoinPay). */} + + {mode === "signup" ? "Sign up with CoinPay" : "Sign in with CoinPay"} + + +
+ or use your email +
+
)} -
diff --git a/src/lib/auth/coinpay-store.ts b/src/lib/auth/coinpay-store.ts new file mode 100644 index 0000000..5fa8562 --- /dev/null +++ b/src/lib/auth/coinpay-store.ts @@ -0,0 +1,82 @@ +import { db, args, one } from "@/lib/db"; +import { ensureTables as ensureNewsletterTables } from "@/lib/newsletter-store"; +import { now } from "./crypto"; +import type { CoinpayStore, IdentityRow } from "./coinpay"; + +// Also in db/schema.pg.sql; created here on first use because nothing applies +// that file on deploy. coinpay_sub is the binding (primary key); user_id is +// unique so one account holds at most one CoinPay identity. +const DDL = [ + `CREATE TABLE IF NOT EXISTS coinpay_identities ( + coinpay_sub text PRIMARY KEY, + user_id bigint NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + coinpay_email text, + coinpay_name text, + created_via text NOT NULL, + created_at bigint NOT NULL, + last_login_at bigint + )`, +]; + +let ready: Promise | null = null; + +export function ensureCoinpayTables(): Promise { + ready ??= (async () => { + await ensureNewsletterTables(); + for (const sql of DDL) await db().execute(sql); + })().catch((err) => { + ready = null; + throw err; + }); + return ready; +} + +const COLS = "user_id, coinpay_sub, coinpay_email, coinpay_name, created_via"; + +function row(r: IdentityRow | null): IdentityRow | null { + return r ? { ...r, user_id: Number(r.user_id) } : null; +} + +export function dbCoinpayStore(): CoinpayStore { + return { + async identityBySub(sub) { + await ensureCoinpayTables(); + return row(await one(`SELECT ${COLS} FROM coinpay_identities WHERE coinpay_sub = ?`, [sub])); + }, + async identityByUser(userId) { + await ensureCoinpayTables(); + return row(await one(`SELECT ${COLS} FROM coinpay_identities WHERE user_id = ?`, [userId])); + }, + async link(r) { + await ensureCoinpayTables(); + const res = await db().execute({ + sql: `INSERT INTO coinpay_identities(coinpay_sub, user_id, coinpay_email, coinpay_name, created_via, created_at, last_login_at) + VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, + args: args([r.coinpay_sub, r.user_id, r.coinpay_email, r.coinpay_name, r.created_via, now(), now()]), + }); + return Number(res.rowsAffected ?? 0) > 0 ? "ok" : "conflict"; + }, + async unlink(userId) { + await ensureCoinpayTables(); + await db().execute({ sql: "DELETE FROM coinpay_identities WHERE user_id = ?", args: args([userId]) }); + }, + async touch(sub) { + await ensureCoinpayTables(); + await db().execute({ sql: "UPDATE coinpay_identities SET last_login_at = ? WHERE coinpay_sub = ?", args: args([now(), sub]) }); + }, + async createUser(email) { + await ensureCoinpayTables(); + // Digest off: the placeholder address cannot receive it. + const res = await db().execute({ + sql: "INSERT INTO users(email, created_at, newsletter) VALUES(?,?,0) RETURNING id", + args: args([email, now()]), + }); + const id = Number((res.rows[0] as unknown as { id: unknown } | undefined)?.id); + if (!Number.isFinite(id) || id <= 0) throw new Error("user could not be created"); + return id; + }, + async deleteUser(userId) { + await db().execute({ sql: "DELETE FROM users WHERE id = ?", args: args([userId]) }); + }, + }; +} diff --git a/src/lib/auth/coinpay.ts b/src/lib/auth/coinpay.ts new file mode 100644 index 0000000..40c66a3 --- /dev/null +++ b/src/lib/auth/coinpay.ts @@ -0,0 +1,214 @@ +// Sign in with CoinPay (CoinPay is the house master account). +// +// Confidential OIDC client: authorization code + PKCE S256 + state + nonce, +// redirect https://p0dcasters.com/api/v1/coinpay/oauth/callback, scopes +// "openid profile email". The protocol half (authorize URL, code exchange, +// id_token checks, userinfo) is @profullstack/coinpay-onboard/oauth. +// +// STANDING security rule: a CoinPay login binds to the CoinPay `sub` in +// coinpay_identities. It never signs in to an existing account because the +// CoinPay email matches (CoinPay never verifies emails), never stores that +// email as the account's email, and never grants anything from it. Linking +// CoinPay to an existing account happens only from /account while signed in. +// A first CoinPay login with no link makes a NEW account whose email is an +// undeliverable placeholder; the CoinPay email is kept on the identity row as +// unverified contact info only. +// +// Nothing here imports the database, so the decision logic is tested in memory +// against the CoinpayStore interface; coinpay-store.ts is the real one. +import { randomBytes } from "node:crypto"; +import { authorizeUrl as cpAuthorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from "@profullstack/coinpay-onboard/oauth"; +import { env, now, seal, unseal } from "./crypto"; +import { safeNext } from "./next"; + +export const COINPAY_BASE = "https://coinpayportal.com"; +export const CALLBACK_PATH = "/api/v1/coinpay/oauth/callback"; +export const PENDING_COOKIE = "p0d_cp_oauth"; +export const PENDING_COOKIE_PATH = "/api/v1/coinpay/oauth"; +export const PENDING_TTL = 600; +/** Accounts a CoinPay login created. `.invalid` can never receive mail (RFC 2606). */ +export const PLACEHOLDER_EMAIL_DOMAIN = "coinpay.p0dcasters.invalid"; + +export type CoinpayConfig = { clientId: string; clientSecret: string; base: string; siteUrl: string }; + +export function coinpayConfig(): CoinpayConfig | null { + const clientId = (env("COINPAY_OAUTH_CLIENT_ID") || "").trim(); + const clientSecret = (env("COINPAY_OAUTH_CLIENT_SECRET") || "").trim(); + if (!clientId || !clientSecret) return null; + const base = (env("COINPAY_OAUTH_ISSUER") || COINPAY_BASE).trim().replace(/\/+$/, ""); + // The redirect URI must match the registration exactly, so it is fixed to + // the canonical host rather than whatever Host header the request carried. + const siteUrl = (env("SITE_URL") || "https://p0dcasters.com").trim().replace(/\/+$/, ""); + return { clientId, clientSecret, base, siteUrl }; +} + +export const redirectUri = (cfg: Pick) => cfg.siteUrl + CALLBACK_PATH; + +export type OAuthMode = "login" | "connect"; +export type PendingAuth = { state: string; verifier: string; nonce: string; mode: OAuthMode; next: string; exp: number }; + +/** A fresh pending sign-in plus the authorize URL that starts it. */ +export function beginSignIn(cfg: CoinpayConfig, mode: OAuthMode, next: string): { pending: PendingAuth; url: string } { + const { verifier, challenge } = pkce(); + const pending: PendingAuth = { + state: randomToken(), + verifier, + nonce: randomToken(), + mode, + next: safeNext(next), + exp: now() + PENDING_TTL, + }; + const url = cpAuthorizeUrl({ + clientId: cfg.clientId, + redirectUri: redirectUri(cfg), + state: pending.state, + nonce: pending.nonce, + challenge, + scopes: SIGN_IN_SCOPES, + base: cfg.base, + }); + return { pending, url }; +} + +/** HMAC-sealed (AUTH_SECRET) so a browser can hold it but not forge or edit it. */ +export function encodePending(p: PendingAuth): string { + return seal(JSON.stringify(p)); +} + +export function decodePending(raw: string | undefined): PendingAuth | null { + const value = unseal(raw); + if (!value) return null; + try { + const o = JSON.parse(value); + if (!o || typeof o.state !== "string" || typeof o.verifier !== "string" || typeof o.nonce !== "string") return null; + if (o.mode !== "login" && o.mode !== "connect") return null; + if (typeof o.exp !== "number" || o.exp < now()) return null; + return { state: o.state, verifier: o.verifier, nonce: o.nonce, mode: o.mode, next: safeNext(o.next), exp: o.exp }; + } catch { + return null; + } +} + +export type CoinpayClaims = { sub: string; email: string | null; name: string | null }; + +export type IdentityRow = { + user_id: number; + coinpay_sub: string; + coinpay_email: string | null; + coinpay_name: string | null; + created_via: "connect" | "signup"; +}; + +export interface CoinpayStore { + identityBySub(sub: string): Promise; + identityByUser(userId: number): Promise; + /** Insert; "conflict" when the sub or the user is already bound. */ + link(row: IdentityRow): Promise<"ok" | "conflict">; + unlink(userId: number): Promise; + touch(sub: string): Promise; + /** A brand-new user with the given placeholder email (digest off). */ + createUser(email: string): Promise; + deleteUser(userId: number): Promise; +} + +export function placeholderEmail(): string { + return `cp-${randomBytes(12).toString("hex")}@${PLACEHOLDER_EMAIL_DOMAIN}`; +} + +export function isPlaceholderEmail(email: string | null | undefined): boolean { + return !!email && email.toLowerCase().endsWith("@" + PLACEHOLDER_EMAIL_DOMAIN); +} + +export type Resolution = + | { ok: true; userId: number; action: "login" | "created" | "linked" } + | { ok: false; code: "not_signed_in" | "linked_elsewhere" | "already_connected"; error: string }; + +/** + * Which p0dcasters user a verified CoinPay sub belongs to. Never looks at the + * email: a CoinPay login carrying an existing user's address gets a NEW account + * (or the one already bound to its sub), never that user's. + */ +export async function resolveCoinpayUser( + input: { mode: OAuthMode; currentUserId: number | null; claims: CoinpayClaims }, + store: CoinpayStore, +): Promise { + const { claims } = input; + const bound = await store.identityBySub(claims.sub); + + if (input.mode === "connect") { + if (!input.currentUserId) { + return { ok: false, code: "not_signed_in", error: "Sign in to p0dcasters first, then connect CoinPay from your account page." }; + } + if (bound) { + if (bound.user_id === input.currentUserId) return { ok: true, userId: bound.user_id, action: "linked" }; + return { ok: false, code: "linked_elsewhere", error: "That CoinPay account is already connected to another p0dcasters account." }; + } + if (await store.identityByUser(input.currentUserId)) { + return { ok: false, code: "already_connected", error: "This account is already connected to a different CoinPay account. Disconnect it first." }; + } + const r = await store.link({ + user_id: input.currentUserId, + coinpay_sub: claims.sub, + coinpay_email: claims.email, + coinpay_name: claims.name, + created_via: "connect", + }); + if (r === "conflict") return { ok: false, code: "linked_elsewhere", error: "That CoinPay account was just connected elsewhere." }; + return { ok: true, userId: input.currentUserId, action: "linked" }; + } + + if (bound) { + await store.touch(claims.sub); + return { ok: true, userId: bound.user_id, action: "login" }; + } + + const userId = await store.createUser(placeholderEmail()); + const r = await store.link({ + user_id: userId, + coinpay_sub: claims.sub, + coinpay_email: claims.email, + coinpay_name: claims.name, + created_via: "signup", + }); + if (r === "conflict") { + // Two first logins raced; the other one won the sub. Use its account. + await store.deleteUser(userId); + const winner = await store.identityBySub(claims.sub); + if (!winner) return { ok: false, code: "linked_elsewhere", error: "CoinPay sign-in raced; try again." }; + return { ok: true, userId: winner.user_id, action: "login" }; + } + return { ok: true, userId, action: "created" }; +} + +/** + * Disconnect is refused when CoinPay is the only way back in: a placeholder + * email cannot receive a sign-in link, so without a passkey the account would + * be locked out for good. + */ +export function canDisconnect(user: { email: string }, passkeyCount: number): boolean { + return !isPlaceholderEmail(user.email) || passkeyCount > 0; +} + +/** Trims what userinfo returned to what is stored; the email is contact info only. */ +export function claimsFrom(r: { sub: string; email: string | null; name: string | null }): CoinpayClaims { + const email = typeof r.email === "string" && r.email.includes("@") ? r.email.slice(0, 320) : null; + const name = typeof r.name === "string" && r.name.trim() ? r.name.trim().slice(0, 200) : null; + return { sub: r.sub, email, name }; +} + +const MESSAGES: Record = { + unavailable: "Sign in with CoinPay is not available right now. Use your email instead.", + expired: "That CoinPay sign-in took too long or was opened in another browser. Try again.", + state: "That CoinPay sign-in could not be verified. Try again.", + denied: "CoinPay sign-in was cancelled.", + failed: "CoinPay could not confirm the sign-in. Try again.", + not_signed_in: "Sign in first, then connect CoinPay from your account page.", + linked_elsewhere: "That CoinPay account is already connected to another p0dcasters account.", + already_connected: "This account is already connected to a different CoinPay account. Disconnect it first.", + only_way_in: "CoinPay is the only way into this account. Add a passkey first, then disconnect.", +}; + +/** The reader-facing line for a ?coinpay= result code, or null. */ +export function coinpayMessage(code: unknown): string | null { + return typeof code === "string" ? MESSAGES[code] ?? null : null; +} diff --git a/test/coinpay-signin.test.mjs b/test/coinpay-signin.test.mjs new file mode 100644 index 0000000..51e9579 --- /dev/null +++ b/test/coinpay-signin.test.mjs @@ -0,0 +1,125 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + beginSignIn, + canDisconnect, + decodePending, + encodePending, + isPlaceholderEmail, + resolveCoinpayUser, +} from '../src/lib/auth/coinpay.ts'; +import { seal } from '../src/lib/auth/crypto.ts'; + +const cfg = { clientId: 'cp_test', clientSecret: 'x', base: 'https://coinpayportal.com', siteUrl: 'https://p0dcasters.com' }; + +/** In-memory CoinpayStore plus a users table with one email-registered victim. */ +function memStore() { + const users = new Map([[1, { id: 1, email: 'victim@example.com' }]]); + const ids = new Map(); + let nextId = 2; + return { + users, + ids, + async identityBySub(sub) { return ids.get(sub) ?? null; }, + async identityByUser(userId) { return [...ids.values()].find((r) => r.user_id === userId) ?? null; }, + async link(row) { + if (ids.has(row.coinpay_sub) || [...ids.values()].some((r) => r.user_id === row.user_id)) return 'conflict'; + ids.set(row.coinpay_sub, row); + return 'ok'; + }, + async unlink(userId) { for (const [k, r] of ids) if (r.user_id === userId) ids.delete(k); }, + async touch() {}, + async createUser(email) { const id = nextId++; users.set(id, { id, email }); return id; }, + async deleteUser(id) { users.delete(id); }, + }; +} + +test('authorize URL carries PKCE S256, state, nonce and the exact redirect', () => { + const { pending, url } = beginSignIn(cfg, 'login', '/following'); + const u = new URL(url); + assert.equal(u.origin + u.pathname, 'https://coinpayportal.com/api/oauth/authorize'); + assert.equal(u.searchParams.get('code_challenge_method'), 'S256'); + assert.ok(u.searchParams.get('code_challenge')); + assert.equal(u.searchParams.get('state'), pending.state); + assert.equal(u.searchParams.get('nonce'), pending.nonce); + assert.equal(u.searchParams.get('redirect_uri'), 'https://p0dcasters.com/api/v1/coinpay/oauth/callback'); + assert.equal(u.searchParams.get('scope'), 'openid profile email'); +}); + +test('pending cookie round-trips, and a forged or edited one is refused', () => { + const { pending } = beginSignIn(cfg, 'login', '/x'); + const sealed = encodePending(pending); + assert.deepEqual(decodePending(sealed), pending); + // Unsigned JSON, or a signed value with the state swapped, are both refused. + assert.equal(decodePending(Buffer.from(JSON.stringify({ ...pending, state: 'evil' })).toString('base64url') + '.bad'), null); + const [body, sig] = sealed.split('.'); + const edited = Buffer.from(JSON.stringify({ ...pending, state: 'evil' })).toString('base64url'); + assert.equal(decodePending(`${edited}.${sig}`), null); + assert.ok(body); + // Expired is refused even with a valid signature. + assert.equal(decodePending(seal(JSON.stringify({ ...pending, exp: 1 }))), null); + assert.equal(decodePending(undefined), null); +}); + +test('an open-redirect next is dropped', () => { + const { pending } = beginSignIn(cfg, 'login', '//evil.com'); + assert.equal(pending.next, ''); +}); + +test('a CoinPay login carrying an existing user email gets a NEW account, not theirs', async () => { + const store = memStore(); + const r = await resolveCoinpayUser( + { mode: 'login', currentUserId: null, claims: { sub: 'attacker-sub', email: 'victim@example.com', name: null } }, + store, + ); + assert.equal(r.ok, true); + assert.equal(r.action, 'created'); + assert.notEqual(r.userId, 1); + assert.ok(isPlaceholderEmail(store.users.get(r.userId).email), 'new account email is a placeholder'); + assert.notEqual(store.users.get(r.userId).email, 'victim@example.com'); + // The second login with the same sub lands on the same new account. + const again = await resolveCoinpayUser( + { mode: 'login', currentUserId: null, claims: { sub: 'attacker-sub', email: 'victim@example.com', name: null } }, + store, + ); + assert.deepEqual([again.ok, again.userId, again.action], [true, r.userId, 'login']); +}); + +test('connecting needs a signed-in session', async () => { + const store = memStore(); + const r = await resolveCoinpayUser({ mode: 'connect', currentUserId: null, claims: { sub: 's1', email: null, name: null } }, store); + assert.equal(r.ok, false); + assert.equal(r.code, 'not_signed_in'); + assert.equal(store.ids.size, 0); +}); + +test('connect links the signed-in account; then CoinPay login reaches it', async () => { + const store = memStore(); + const c = await resolveCoinpayUser({ mode: 'connect', currentUserId: 1, claims: { sub: 's1', email: 'other@x.com', name: null } }, store); + assert.deepEqual([c.ok, c.userId], [true, 1]); + const l = await resolveCoinpayUser({ mode: 'login', currentUserId: null, claims: { sub: 's1', email: 'other@x.com', name: null } }, store); + assert.deepEqual([l.ok, l.userId, l.action], [true, 1, 'login']); + // A sub already bound elsewhere cannot be connected to another account. + await store.createUser('b@x.com'); + const steal = await resolveCoinpayUser({ mode: 'connect', currentUserId: 2, claims: { sub: 's1', email: null, name: null } }, store); + assert.equal(steal.code, 'linked_elsewhere'); +}); + +test('disconnect is refused when CoinPay is the only way in', () => { + assert.equal(canDisconnect({ email: 'cp-ab@coinpay.p0dcasters.invalid' }, 0), false); + assert.equal(canDisconnect({ email: 'cp-ab@coinpay.p0dcasters.invalid' }, 1), true); + assert.equal(canDisconnect({ email: 'real@example.com' }, 0), true); +}); + +test('callback checks: wrong state and wrong nonce are rejected', async () => { + const { sameState, checkIdToken } = await import('@profullstack/coinpay-onboard/oauth'); + assert.equal(sameState('abc', 'abd'), false); + assert.equal(sameState('abc', null), false); + assert.equal(sameState('abc', 'abc'), true); + const part = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + const tok = (claims) => `${part({ alg: 'RS256' })}.${part(claims)}.sig`; + const good = { iss: 'https://coinpayportal.com', sub: 's', aud: 'cp_test', exp: Math.floor(Date.now() / 1000) + 300, nonce: 'n1' }; + assert.equal(checkIdToken({ idToken: tok(good), clientId: 'cp_test', nonce: 'n1' }).sub, 's'); + assert.throws(() => checkIdToken({ idToken: tok(good), clientId: 'cp_test', nonce: 'other' })); + assert.throws(() => checkIdToken({ idToken: tok({ ...good, aud: 'cp_else' }), clientId: 'cp_test', nonce: 'n1' })); +});