diff --git a/.gitignore b/.gitignore index 545a6e2..4bb2dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ web/node_modules/ web/dist/ +node_modules/ +dist/ +data/ +*.db +*.db-wal +*.db-shm diff --git a/package-lock.json b/package-lock.json index f296393..a5b7d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "better-sqlite3": "^12.8.0", "jsonwebtoken": "^9.0.3", + "mysql2": "^3.20.0", "sodium-native": "^5.1.0", "typescript": "^5.9.3", "uuid": "^13.0.0", "ws": "^8.19.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/sodium-native": "^2.3.9", @@ -825,6 +828,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -872,7 +885,6 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1028,6 +1040,15 @@ "node": ">=12" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -1167,6 +1188,84 @@ "bare": ">=1.2.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1183,6 +1282,12 @@ "node": ">=18" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1190,11 +1295,43 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1209,6 +1346,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1277,6 +1423,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1311,6 +1466,18 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1326,6 +1493,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1339,6 +1515,66 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1685,6 +1921,27 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1695,12 +1952,73 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1720,6 +2038,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1731,6 +2067,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1787,6 +2132,72 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-addon": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", @@ -1863,6 +2274,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1882,6 +2299,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sodium-native": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", @@ -1906,6 +2368,21 @@ "node": ">=0.10.0" } }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1931,6 +2408,52 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -2021,6 +2544,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2038,7 +2573,12 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { @@ -2238,6 +2778,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index dd5e138..8a64731 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,16 @@ "license": "MIT", "type": "commonjs", "dependencies": { + "better-sqlite3": "^12.8.0", "jsonwebtoken": "^9.0.3", + "mysql2": "^3.20.0", "sodium-native": "^5.1.0", "typescript": "^5.9.3", "uuid": "^13.0.0", "ws": "^8.19.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/sodium-native": "^2.3.9", diff --git a/src/cli.ts b/src/cli.ts index 78e1c41..4eb6ab3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,21 +1,39 @@ import { RelayServer } from "./relay/index.js"; +import { loadDbConfig, createStores } from "./relay/db/index.js"; const port = parseInt(process.env.PORT ?? "8080", 10); const host = process.env.HOST ?? "0.0.0.0"; -const server = new RelayServer({ port, host }); +async function main() { + // Load saved database config (if any) + const dbConfig = loadDbConfig(); + let stores; + if (dbConfig) { + console.log(`Loading ${dbConfig.type} database...`); + stores = await createStores(dbConfig); + } else { + console.log("No database configured — starting with in-memory storage."); + console.log("Configure a database during setup to enable persistent storage."); + } -server.start().then(() => { + const server = new RelayServer({ port, host, stores }); + + await server.start(); console.log(`CookieBridge relay server listening on ${host}:${port}`); -}); -process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await server.stop(); - process.exit(0); -}); + process.on("SIGINT", async () => { + console.log("\nShutting down..."); + await server.stop(); + process.exit(0); + }); -process.on("SIGTERM", async () => { - await server.stop(); - process.exit(0); + process.on("SIGTERM", async () => { + await server.stop(); + process.exit(0); + }); +} + +main().catch((err) => { + console.error("Failed to start server:", err); + process.exit(1); }); diff --git a/src/relay/admin/routes.ts b/src/relay/admin/routes.ts index c48a8d3..e0aae03 100644 --- a/src/relay/admin/routes.ts +++ b/src/relay/admin/routes.ts @@ -1,14 +1,15 @@ import http from "node:http"; -import type { AdminStore } from "./auth.js"; import type { ConnectionManager } from "../connections.js"; -import type { CookieBlobStore } from "../store.js"; -import type { DeviceRegistry } from "../tokens.js"; +import type { ICookieStore, IDeviceStore, IAdminStore, DbConfig } from "../db/types.js"; +import { saveDbConfig, loadDbConfig, createStores } from "../db/index.js"; +import type { RelayServer } from "../server.js"; export interface AdminDeps { - adminStore: AdminStore; + adminStore: IAdminStore; connections: ConnectionManager; - cookieStore: CookieBlobStore; - deviceRegistry: DeviceRegistry; + cookieStore: ICookieStore; + deviceRegistry: IDeviceStore; + server: RelayServer; } /** @@ -27,7 +28,12 @@ export function handleAdminRoute( // --- Public routes (no auth) --- if (method === "GET" && url === "/admin/setup/status") { - json(res, 200, { isSetUp: deps.adminStore.isSetUp }); + const dbConfig = loadDbConfig(); + json(res, 200, { + isSetUp: deps.adminStore.isSetUp, + dbConfigured: dbConfig !== null, + dbType: dbConfig?.type ?? null, + }); return true; } @@ -107,7 +113,7 @@ export function handleAdminRoute( function authenticate( req: http.IncomingMessage, - store: AdminStore, + store: IAdminStore, ): { sub: string; role: string } | null { const auth = req.headers.authorization; if (!auth?.startsWith("Bearer ")) return null; @@ -127,11 +133,32 @@ function handleSetupInit( ): void { readBody(req, async (body) => { try { - const { username, password } = JSON.parse(body); + const { username, password, dbConfig } = JSON.parse(body) as { + username: string; + password: string; + dbConfig?: DbConfig; + }; if (!username || !password) { json(res, 400, { error: "Missing username or password" }); return; } + + // If a database config is provided, initialize the database first + if (dbConfig) { + try { + const stores = await createStores(dbConfig); + saveDbConfig(dbConfig); + deps.server.replaceStores(stores); + // Update deps references to point to new stores + deps.adminStore = stores.adminStore; + deps.cookieStore = stores.cookieStore; + deps.deviceRegistry = stores.deviceStore; + } catch (err) { + json(res, 500, { error: `Database connection failed: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + } + if (deps.adminStore.isSetUp) { json(res, 409, { error: "Already configured" }); return; @@ -165,13 +192,13 @@ function handleLogin( }); } -function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void { - const devices = deps.deviceRegistry.listAll(); +async function handleDashboard(res: http.ServerResponse, deps: AdminDeps): Promise { + const devices = await deps.deviceRegistry.listAll(); const onlineDeviceIds = devices .filter((d) => deps.connections.isOnline(d.deviceId)) .map((d) => d.deviceId); - const allCookies = deps.cookieStore.getAll(); + const allCookies = await deps.cookieStore.getAll(); const domains = new Set(allCookies.map((c) => c.domain)); @@ -184,17 +211,17 @@ function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void { }); } -function handleCookieList( +async function handleCookieList( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, -): void { +): Promise { const parsed = new URL(req.url ?? "", `http://${req.headers.host}`); // Check if this is a single cookie detail request: /admin/cookies/:id const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/); if (idMatch) { - const cookie = deps.cookieStore.getById(idMatch[1]); + const cookie = await deps.cookieStore.getById(idMatch[1]); if (!cookie) { json(res, 404, { error: "Cookie not found" }); return; @@ -208,7 +235,7 @@ function handleCookieList( const page = parseInt(parsed.searchParams.get("page") ?? "1", 10); const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200); - let cookies = deps.cookieStore.getAll(); + let cookies = await deps.cookieStore.getAll(); if (domain) { cookies = cookies.filter((c) => c.domain === domain); @@ -227,18 +254,18 @@ function handleCookieList( json(res, 200, { items, total, page, limit }); } -function handleCookieDeleteById( +async function handleCookieDeleteById( _req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, -): void { +): Promise { const parsed = new URL(_req.url ?? "", `http://${_req.headers.host}`); const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/); if (!idMatch) { json(res, 400, { error: "Invalid cookie ID" }); return; } - const deleted = deps.cookieStore.deleteById(idMatch[1]); + const deleted = await deps.cookieStore.deleteById(idMatch[1]); json(res, 200, { deleted }); } @@ -247,7 +274,7 @@ function handleCookieBatchDelete( res: http.ServerResponse, deps: AdminDeps, ): void { - readBody(req, (body) => { + readBody(req, async (body) => { try { const { ids } = JSON.parse(body) as { ids: string[] }; if (!ids || !Array.isArray(ids)) { @@ -256,7 +283,7 @@ function handleCookieBatchDelete( } let count = 0; for (const id of ids) { - if (deps.cookieStore.deleteById(id)) count++; + if (await deps.cookieStore.deleteById(id)) count++; } json(res, 200, { deleted: count }); } catch { @@ -265,8 +292,8 @@ function handleCookieBatchDelete( }); } -function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void { - const devices = deps.deviceRegistry.listAll().map((d) => ({ +async function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): Promise { + const devices = (await deps.deviceRegistry.listAll()).map((d) => ({ deviceId: d.deviceId, name: d.name, platform: d.platform, @@ -276,11 +303,11 @@ function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void { json(res, 200, { devices }); } -function handleDeviceRevoke( +async function handleDeviceRevoke( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, -): void { +): Promise { const parsed = new URL(req.url ?? "", `http://${req.headers.host}`); const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/); if (!match) { @@ -288,7 +315,7 @@ function handleDeviceRevoke( return; } const deviceId = match[1]; - const revoked = deps.deviceRegistry.revoke(deviceId); + const revoked = await deps.deviceRegistry.revoke(deviceId); if (revoked) { deps.connections.disconnect(deviceId); } diff --git a/src/relay/db/index.ts b/src/relay/db/index.ts new file mode 100644 index 0000000..e43cce9 --- /dev/null +++ b/src/relay/db/index.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { DataStores, DbConfig } from "./types.js"; +import { createMemoryStores } from "./memory.js"; +import { createSqliteStores } from "./sqlite.js"; +import { createMysqlStores } from "./mysql.js"; + +export type { DataStores, DbConfig, DbType, SqliteConfig, MysqlConfig, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./types.js"; + +const DEFAULT_CONFIG_DIR = path.join(process.cwd(), "data"); +const CONFIG_FILE = "db-config.json"; + +export function getConfigPath(): string { + return path.join(DEFAULT_CONFIG_DIR, CONFIG_FILE); +} + +/** Load saved database config, or null if not yet configured. */ +export function loadDbConfig(): DbConfig | null { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) return null; + try { + const raw = fs.readFileSync(configPath, "utf-8"); + return JSON.parse(raw) as DbConfig; + } catch { + return null; + } +} + +/** Save database config to disk. */ +export function saveDbConfig(config: DbConfig): void { + const configPath = getConfigPath(); + const dir = path.dirname(configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); +} + +/** Create data stores from config. If no config exists, returns null (setup required). */ +export async function createStores(config: DbConfig): Promise { + switch (config.type) { + case "sqlite": { + // Ensure the directory for the SQLite file exists + const dir = path.dirname(config.path); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return createSqliteStores(config); + } + case "mysql": + return createMysqlStores(config); + default: + throw new Error(`Unknown database type: ${(config as DbConfig).type}`); + } +} + +/** Create in-memory stores (for backwards compatibility / testing). */ +export { createMemoryStores } from "./memory.js"; diff --git a/src/relay/db/memory.ts b/src/relay/db/memory.ts new file mode 100644 index 0000000..c046e81 --- /dev/null +++ b/src/relay/db/memory.ts @@ -0,0 +1,126 @@ +import { CookieBlobStore } from "../store.js"; +import { DeviceRegistry, AgentRegistry } from "../tokens.js"; +import { AdminStore } from "../admin/auth.js"; +import type { + ICookieStore, + IDeviceStore, + IAgentStore, + IAdminStore, + DataStores, +} from "./types.js"; +import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js"; +import type { AdminSettings } from "../admin/auth.js"; + +class MemoryCookieStore implements ICookieStore { + private inner = new CookieBlobStore(); + + async upsert(blob: Omit): Promise { + return this.inner.upsert(blob); + } + async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise { + return this.inner.delete(deviceId, domain, cookieName, path); + } + async deleteById(id: string): Promise { + return this.inner.deleteById(id); + } + async getByDevice(deviceId: string, domain?: string): Promise { + return this.inner.getByDevice(deviceId, domain); + } + async getByDevices(deviceIds: string[], domain?: string): Promise { + return this.inner.getByDevices(deviceIds, domain); + } + async getAll(): Promise { + return this.inner.getAll(); + } + async getById(id: string): Promise { + return this.inner.getById(id); + } + async getUpdatedSince(deviceIds: string[], since: string): Promise { + return this.inner.getUpdatedSince(deviceIds, since); + } +} + +class MemoryDeviceStore implements IDeviceStore { + private inner = new DeviceRegistry(); + + async register(deviceId: string, name: string, platform: string, encPub: string): Promise { + return this.inner.register(deviceId, name, platform, encPub); + } + async getByToken(token: string): Promise { + return this.inner.getByToken(token); + } + async getById(deviceId: string): Promise { + return this.inner.getById(deviceId); + } + async addPairing(deviceIdA: string, deviceIdB: string): Promise { + this.inner.addPairing(deviceIdA, deviceIdB); + } + async getPairedDevices(deviceId: string): Promise { + return this.inner.getPairedDevices(deviceId); + } + async getPairingGroup(deviceId: string): Promise { + return this.inner.getPairingGroup(deviceId); + } + async listAll(): Promise { + return this.inner.listAll(); + } + async revoke(deviceId: string): Promise { + return this.inner.revoke(deviceId); + } +} + +class MemoryAgentStore implements IAgentStore { + private inner = new AgentRegistry(); + + async create(name: string, encPub: string, allowedDomains: string[]): Promise { + return this.inner.create(name, encPub, allowedDomains); + } + async getByToken(token: string): Promise { + return this.inner.getByToken(token); + } + async grantAccess(agentId: string, deviceId: string): Promise { + this.inner.grantAccess(agentId, deviceId); + } + async getAccessibleDevices(agentId: string): Promise { + return this.inner.getAccessibleDevices(agentId); + } + async revokeAccess(agentId: string, deviceId: string): Promise { + this.inner.revokeAccess(agentId, deviceId); + } +} + +class MemoryAdminStore implements IAdminStore { + private inner = new AdminStore(); + + get isSetUp(): boolean { + return this.inner.isSetUp; + } + async setup(username: string, password: string): Promise { + return this.inner.setup(username, password); + } + async login(username: string, password: string): Promise { + return this.inner.login(username, password); + } + verifyToken(token: string): { sub: string; role: string } { + return this.inner.verifyToken(token); + } + getUser(): { username: string; createdAt: string } | null { + return this.inner.getUser(); + } + getSettings(): AdminSettings { + return this.inner.getSettings(); + } + updateSettings(patch: Partial): AdminSettings { + return this.inner.updateSettings(patch); + } +} + +export function createMemoryStores(): DataStores { + return { + cookieStore: new MemoryCookieStore(), + deviceStore: new MemoryDeviceStore(), + agentStore: new MemoryAgentStore(), + adminStore: new MemoryAdminStore(), + async close() { /* no-op */ }, + }; +} diff --git a/src/relay/db/mysql.ts b/src/relay/db/mysql.ts new file mode 100644 index 0000000..2208856 --- /dev/null +++ b/src/relay/db/mysql.ts @@ -0,0 +1,530 @@ +import mysql from "mysql2/promise"; +import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; +import sodium from "sodium-native"; +import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js"; +import { MAX_STORED_COOKIES_PER_DEVICE } from "../../protocol/spec.js"; +import type { AdminSettings } from "../admin/auth.js"; +import type { + ICookieStore, + IDeviceStore, + IAgentStore, + IAdminStore, + DataStores, + MysqlConfig, +} from "./types.js"; + +const SCRYPT_KEYLEN = 64; +const SCRYPT_COST = 16384; +const SCRYPT_BLOCK_SIZE = 8; +const SCRYPT_PARALLELISM = 1; + +const DEFAULT_SETTINGS: AdminSettings = { + syncIntervalMs: 30_000, + maxDevices: 10, + autoSync: true, + theme: "system", +}; + +function generateId(): string { + const buf = Buffer.alloc(16); + sodium.randombytes_buf(buf); + return buf.toString("hex"); +} + +function generateToken(): string { + const buf = Buffer.alloc(32); + sodium.randombytes_buf(buf); + return "cb_" + buf.toString("hex"); +} + +function hashPassword(password: string, salt: string): Promise { + return new Promise((resolve, reject) => { + crypto.scrypt( + password, + Buffer.from(salt, "hex"), + SCRYPT_KEYLEN, + { N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM }, + (err, derived) => { + if (err) reject(err); + else resolve(derived.toString("hex")); + }, + ); + }); +} + +async function initSchema(pool: mysql.Pool): Promise { + await pool.execute(` + CREATE TABLE IF NOT EXISTS cookies ( + id VARCHAR(64) PRIMARY KEY, + device_id VARCHAR(128) NOT NULL, + domain VARCHAR(255) NOT NULL, + cookie_name VARCHAR(255) NOT NULL, + path VARCHAR(512) NOT NULL, + nonce TEXT NOT NULL, + ciphertext LONGTEXT NOT NULL, + lamport_ts BIGINT NOT NULL, + updated_at VARCHAR(64) NOT NULL, + UNIQUE KEY uk_cookie (device_id, domain, cookie_name, path(191)), + INDEX idx_cookies_device (device_id), + INDEX idx_cookies_domain (domain), + INDEX idx_cookies_updated (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS devices ( + device_id VARCHAR(128) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + platform VARCHAR(64) NOT NULL, + enc_pub VARCHAR(128) NOT NULL, + token VARCHAR(128) NOT NULL, + created_at VARCHAR(64) NOT NULL, + UNIQUE KEY uk_device_token (token) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS pairings ( + device_id_a VARCHAR(128) NOT NULL, + device_id_b VARCHAR(128) NOT NULL, + PRIMARY KEY (device_id_a, device_id_b) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS agents ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + token VARCHAR(128) NOT NULL, + enc_pub VARCHAR(128) NOT NULL, + allowed_domains TEXT NOT NULL, + created_at VARCHAR(64) NOT NULL, + UNIQUE KEY uk_agent_token (token) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS agent_device_access ( + agent_id VARCHAR(64) NOT NULL, + device_id VARCHAR(128) NOT NULL, + PRIMARY KEY (agent_id, device_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS admin_users ( + username VARCHAR(255) PRIMARY KEY, + password_hash VARCHAR(255) NOT NULL, + salt VARCHAR(64) NOT NULL, + created_at VARCHAR(64) NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS admin_settings ( + \`key\` VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS admin_meta ( + \`key\` VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); +} + +function toBlob(row: Record): EncryptedCookieBlob { + return { + id: row.id as string, + deviceId: row.device_id as string, + domain: row.domain as string, + cookieName: row.cookie_name as string, + path: row.path as string, + nonce: row.nonce as string, + ciphertext: row.ciphertext as string, + lamportTs: Number(row.lamport_ts), + updatedAt: row.updated_at as string, + }; +} + +function toDevice(row: Record): DeviceInfo { + return { + deviceId: row.device_id as string, + name: row.name as string, + platform: row.platform as string, + encPub: row.enc_pub as string, + token: row.token as string, + createdAt: row.created_at as string, + }; +} + +function toAgent(row: Record): AgentToken { + return { + id: row.id as string, + name: row.name as string, + token: row.token as string, + encPub: row.enc_pub as string, + allowedDomains: JSON.parse(row.allowed_domains as string), + createdAt: row.created_at as string, + }; +} + +// --- MySQL Cookie Store --- + +class MysqlCookieStore implements ICookieStore { + constructor(private pool: mysql.Pool) {} + + async upsert(blob: Omit): Promise { + const [rows] = await this.pool.execute( + "SELECT * FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?", + [blob.deviceId, blob.domain, blob.cookieName, blob.path], + ); + const existing = (rows as Record[])[0]; + + if (existing && Number(existing.lamport_ts) >= blob.lamportTs) { + return toBlob(existing); + } + + const id = existing ? existing.id as string : generateId(); + const updatedAt = new Date().toISOString(); + + await this.pool.execute( + `INSERT INTO cookies (id, device_id, domain, cookie_name, path, nonce, ciphertext, lamport_ts, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + nonce = VALUES(nonce), + ciphertext = VALUES(ciphertext), + lamport_ts = VALUES(lamport_ts), + updated_at = VALUES(updated_at)`, + [id, blob.deviceId, blob.domain, blob.cookieName, blob.path, blob.nonce, blob.ciphertext, blob.lamportTs, updatedAt], + ); + + // Enforce per-device limit + const [countRows] = await this.pool.execute( + "SELECT COUNT(*) as cnt FROM cookies WHERE device_id = ?", + [blob.deviceId], + ); + const count = Number((countRows as Record[])[0].cnt); + if (count > MAX_STORED_COOKIES_PER_DEVICE) { + await this.pool.execute( + "DELETE FROM cookies WHERE id IN (SELECT id FROM (SELECT id FROM cookies WHERE device_id = ? ORDER BY updated_at ASC LIMIT ?) AS tmp)", + [blob.deviceId, count - MAX_STORED_COOKIES_PER_DEVICE], + ); + } + + return { ...blob, id, updatedAt }; + } + + async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise { + const [result] = await this.pool.execute( + "DELETE FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?", + [deviceId, domain, cookieName, path], + ); + return (result as mysql.ResultSetHeader).affectedRows > 0; + } + + async deleteById(id: string): Promise { + const [result] = await this.pool.execute("DELETE FROM cookies WHERE id = ?", [id]); + return (result as mysql.ResultSetHeader).affectedRows > 0; + } + + async getByDevice(deviceId: string, domain?: string): Promise { + if (domain) { + const [rows] = await this.pool.execute( + "SELECT * FROM cookies WHERE device_id = ? AND domain = ?", + [deviceId, domain], + ); + return (rows as Record[]).map(toBlob); + } + const [rows] = await this.pool.execute("SELECT * FROM cookies WHERE device_id = ?", [deviceId]); + return (rows as Record[]).map(toBlob); + } + + async getByDevices(deviceIds: string[], domain?: string): Promise { + if (deviceIds.length === 0) return []; + const placeholders = deviceIds.map(() => "?").join(","); + if (domain) { + const [rows] = await this.pool.execute( + `SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND domain = ?`, + [...deviceIds, domain], + ); + return (rows as Record[]).map(toBlob); + } + const [rows] = await this.pool.execute( + `SELECT * FROM cookies WHERE device_id IN (${placeholders})`, + deviceIds, + ); + return (rows as Record[]).map(toBlob); + } + + async getAll(): Promise { + const [rows] = await this.pool.execute("SELECT * FROM cookies"); + return (rows as Record[]).map(toBlob); + } + + async getById(id: string): Promise { + const [rows] = await this.pool.execute("SELECT * FROM cookies WHERE id = ?", [id]); + const row = (rows as Record[])[0]; + return row ? toBlob(row) : null; + } + + async getUpdatedSince(deviceIds: string[], since: string): Promise { + if (deviceIds.length === 0) return []; + const placeholders = deviceIds.map(() => "?").join(","); + const [rows] = await this.pool.execute( + `SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND updated_at > ?`, + [...deviceIds, since], + ); + return (rows as Record[]).map(toBlob); + } +} + +// --- MySQL Device Store --- + +class MysqlDeviceStore implements IDeviceStore { + constructor(private pool: mysql.Pool) {} + + async register(deviceId: string, name: string, platform: string, encPub: string): Promise { + const [rows] = await this.pool.execute("SELECT * FROM devices WHERE device_id = ?", [deviceId]); + const existing = (rows as Record[])[0]; + if (existing) return toDevice(existing); + + const token = generateToken(); + const createdAt = new Date().toISOString(); + await this.pool.execute( + "INSERT INTO devices (device_id, name, platform, enc_pub, token, created_at) VALUES (?, ?, ?, ?, ?, ?)", + [deviceId, name, platform, encPub, token, createdAt], + ); + return { deviceId, name, platform, encPub, token, createdAt }; + } + + async getByToken(token: string): Promise { + const [rows] = await this.pool.execute("SELECT * FROM devices WHERE token = ?", [token]); + const row = (rows as Record[])[0]; + return row ? toDevice(row) : null; + } + + async getById(deviceId: string): Promise { + const [rows] = await this.pool.execute("SELECT * FROM devices WHERE device_id = ?", [deviceId]); + const row = (rows as Record[])[0]; + return row ? toDevice(row) : null; + } + + async addPairing(deviceIdA: string, deviceIdB: string): Promise { + await this.pool.execute( + "INSERT IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)", + [deviceIdA, deviceIdB], + ); + await this.pool.execute( + "INSERT IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)", + [deviceIdB, deviceIdA], + ); + } + + async getPairedDevices(deviceId: string): Promise { + const [rows] = await this.pool.execute( + "SELECT device_id_b FROM pairings WHERE device_id_a = ?", + [deviceId], + ); + return (rows as { device_id_b: string }[]).map((r) => r.device_id_b); + } + + async getPairingGroup(deviceId: string): Promise { + const paired = await this.getPairedDevices(deviceId); + return [deviceId, ...paired]; + } + + async listAll(): Promise { + const [rows] = await this.pool.execute("SELECT * FROM devices"); + return (rows as Record[]).map(toDevice); + } + + async revoke(deviceId: string): Promise { + const [result] = await this.pool.execute("DELETE FROM devices WHERE device_id = ?", [deviceId]); + if ((result as mysql.ResultSetHeader).affectedRows === 0) return false; + await this.pool.execute( + "DELETE FROM pairings WHERE device_id_a = ? OR device_id_b = ?", + [deviceId, deviceId], + ); + return true; + } +} + +// --- MySQL Agent Store --- + +class MysqlAgentStore implements IAgentStore { + constructor(private pool: mysql.Pool) {} + + async create(name: string, encPub: string, allowedDomains: string[]): Promise { + const id = generateId(); + const token = generateToken(); + const createdAt = new Date().toISOString(); + await this.pool.execute( + "INSERT INTO agents (id, name, token, enc_pub, allowed_domains, created_at) VALUES (?, ?, ?, ?, ?, ?)", + [id, name, token, encPub, JSON.stringify(allowedDomains), createdAt], + ); + return { id, name, token, encPub, allowedDomains, createdAt }; + } + + async getByToken(token: string): Promise { + const [rows] = await this.pool.execute("SELECT * FROM agents WHERE token = ?", [token]); + const row = (rows as Record[])[0]; + return row ? toAgent(row) : null; + } + + async grantAccess(agentId: string, deviceId: string): Promise { + await this.pool.execute( + "INSERT IGNORE INTO agent_device_access (agent_id, device_id) VALUES (?, ?)", + [agentId, deviceId], + ); + } + + async getAccessibleDevices(agentId: string): Promise { + const [rows] = await this.pool.execute( + "SELECT device_id FROM agent_device_access WHERE agent_id = ?", + [agentId], + ); + return (rows as { device_id: string }[]).map((r) => r.device_id); + } + + async revokeAccess(agentId: string, deviceId: string): Promise { + await this.pool.execute( + "DELETE FROM agent_device_access WHERE agent_id = ? AND device_id = ?", + [agentId, deviceId], + ); + } +} + +// --- MySQL Admin Store --- + +class MysqlAdminStore implements IAdminStore { + private jwtSecret!: string; + private _settings: AdminSettings = { ...DEFAULT_SETTINGS }; + private _isSetUp: boolean = false; + private _user: { username: string; createdAt: string } | null = null; + + constructor(private pool: mysql.Pool) {} + + async initialize(): Promise { + // Load or generate JWT secret + const [metaRows] = await this.pool.execute( + "SELECT value FROM admin_meta WHERE `key` = 'jwt_secret'", + ); + const meta = (metaRows as { value: string }[])[0]; + if (meta) { + this.jwtSecret = meta.value; + } else { + this.jwtSecret = crypto.randomBytes(32).toString("hex"); + await this.pool.execute( + "INSERT INTO admin_meta (`key`, value) VALUES ('jwt_secret', ?)", + [this.jwtSecret], + ); + } + + // Load settings + const [settingsRows] = await this.pool.execute( + "SELECT value FROM admin_settings WHERE `key` = 'settings'", + ); + const settingsRow = (settingsRows as { value: string }[])[0]; + if (settingsRow) { + Object.assign(this._settings, JSON.parse(settingsRow.value)); + } + + // Check setup status + const [countRows] = await this.pool.execute("SELECT COUNT(*) as cnt FROM admin_users"); + this._isSetUp = Number((countRows as { cnt: number }[])[0].cnt) > 0; + + if (this._isSetUp) { + const [userRows] = await this.pool.execute( + "SELECT username, created_at FROM admin_users LIMIT 1", + ); + const userRow = (userRows as Record[])[0]; + if (userRow) { + this._user = { username: userRow.username as string, createdAt: userRow.created_at as string }; + } + } + } + + get isSetUp(): boolean { + return this._isSetUp; + } + + async setup(username: string, password: string): Promise { + if (this._isSetUp) throw new Error("Already configured"); + const salt = crypto.randomBytes(16).toString("hex"); + const hash = await hashPassword(password, salt); + const createdAt = new Date().toISOString(); + await this.pool.execute( + "INSERT INTO admin_users (username, password_hash, salt, created_at) VALUES (?, ?, ?, ?)", + [username, hash, salt, createdAt], + ); + this._isSetUp = true; + this._user = { username, createdAt }; + } + + async login(username: string, password: string): Promise { + const [rows] = await this.pool.execute( + "SELECT * FROM admin_users WHERE username = ?", + [username], + ); + const user = (rows as Record[])[0]; + if (!user) throw new Error("Invalid credentials"); + const hash = await hashPassword(password, user.salt as string); + if (hash !== user.password_hash) throw new Error("Invalid credentials"); + return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" }); + } + + verifyToken(token: string): { sub: string; role: string } { + return jwt.verify(token, this.jwtSecret) as { sub: string; role: string }; + } + + getUser(): { username: string; createdAt: string } | null { + return this._user; + } + + getSettings(): AdminSettings { + return { ...this._settings }; + } + + updateSettings(patch: Partial): AdminSettings { + Object.assign(this._settings, patch); + // Fire and forget the DB write + this.pool.execute( + "INSERT INTO admin_settings (`key`, value) VALUES ('settings', ?) ON DUPLICATE KEY UPDATE value = VALUES(value)", + [JSON.stringify(this._settings)], + ); + return { ...this._settings }; + } +} + +// --- Factory --- + +export async function createMysqlStores(config: MysqlConfig): Promise { + const pool = mysql.createPool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + waitForConnections: true, + connectionLimit: 10, + }); + + await initSchema(pool); + + const adminStore = new MysqlAdminStore(pool); + await adminStore.initialize(); + + return { + cookieStore: new MysqlCookieStore(pool), + deviceStore: new MysqlDeviceStore(pool), + agentStore: new MysqlAgentStore(pool), + adminStore, + async close() { + await pool.end(); + }, + }; +} diff --git a/src/relay/db/sqlite.ts b/src/relay/db/sqlite.ts new file mode 100644 index 0000000..7b37ee2 --- /dev/null +++ b/src/relay/db/sqlite.ts @@ -0,0 +1,431 @@ +import Database from "better-sqlite3"; +import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; +import sodium from "sodium-native"; +import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js"; +import { MAX_STORED_COOKIES_PER_DEVICE } from "../../protocol/spec.js"; +import type { AdminSettings } from "../admin/auth.js"; +import type { + ICookieStore, + IDeviceStore, + IAgentStore, + IAdminStore, + DataStores, + SqliteConfig, +} from "./types.js"; + +const SCRYPT_KEYLEN = 64; +const SCRYPT_COST = 16384; +const SCRYPT_BLOCK_SIZE = 8; +const SCRYPT_PARALLELISM = 1; + +const DEFAULT_SETTINGS: AdminSettings = { + syncIntervalMs: 30_000, + maxDevices: 10, + autoSync: true, + theme: "system", +}; + +function generateId(): string { + const buf = Buffer.alloc(16); + sodium.randombytes_buf(buf); + return buf.toString("hex"); +} + +function generateToken(): string { + const buf = Buffer.alloc(32); + sodium.randombytes_buf(buf); + return "cb_" + buf.toString("hex"); +} + +function hashPassword(password: string, salt: string): Promise { + return new Promise((resolve, reject) => { + crypto.scrypt( + password, + Buffer.from(salt, "hex"), + SCRYPT_KEYLEN, + { N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM }, + (err, derived) => { + if (err) reject(err); + else resolve(derived.toString("hex")); + }, + ); + }); +} + +function initSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS cookies ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + domain TEXT NOT NULL, + cookie_name TEXT NOT NULL, + path TEXT NOT NULL, + nonce TEXT NOT NULL, + ciphertext TEXT NOT NULL, + lamport_ts INTEGER NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(device_id, domain, cookie_name, path) + ); + + CREATE INDEX IF NOT EXISTS idx_cookies_device ON cookies(device_id); + CREATE INDEX IF NOT EXISTS idx_cookies_domain ON cookies(domain); + CREATE INDEX IF NOT EXISTS idx_cookies_updated ON cookies(updated_at); + + CREATE TABLE IF NOT EXISTS devices ( + device_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + platform TEXT NOT NULL, + enc_pub TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pairings ( + device_id_a TEXT NOT NULL, + device_id_b TEXT NOT NULL, + PRIMARY KEY (device_id_a, device_id_b) + ); + + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + enc_pub TEXT NOT NULL, + allowed_domains TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS agent_device_access ( + agent_id TEXT NOT NULL, + device_id TEXT NOT NULL, + PRIMARY KEY (agent_id, device_id) + ); + + CREATE TABLE IF NOT EXISTS admin_users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS admin_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS admin_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); +} + +function toBlob(row: Record): EncryptedCookieBlob { + return { + id: row.id as string, + deviceId: row.device_id as string, + domain: row.domain as string, + cookieName: row.cookie_name as string, + path: row.path as string, + nonce: row.nonce as string, + ciphertext: row.ciphertext as string, + lamportTs: row.lamport_ts as number, + updatedAt: row.updated_at as string, + }; +} + +function toDevice(row: Record): DeviceInfo { + return { + deviceId: row.device_id as string, + name: row.name as string, + platform: row.platform as string, + encPub: row.enc_pub as string, + token: row.token as string, + createdAt: row.created_at as string, + }; +} + +function toAgent(row: Record): AgentToken { + return { + id: row.id as string, + name: row.name as string, + token: row.token as string, + encPub: row.enc_pub as string, + allowedDomains: JSON.parse(row.allowed_domains as string), + createdAt: row.created_at as string, + }; +} + +// --- SQLite Cookie Store --- + +class SqliteCookieStore implements ICookieStore { + constructor(private db: Database.Database) {} + + async upsert(blob: Omit): Promise { + const existing = this.db.prepare( + "SELECT * FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?", + ).get(blob.deviceId, blob.domain, blob.cookieName, blob.path) as Record | undefined; + + if (existing && (existing.lamport_ts as number) >= blob.lamportTs) { + return toBlob(existing); + } + + const id = existing ? existing.id as string : generateId(); + const updatedAt = new Date().toISOString(); + + this.db.prepare(` + INSERT INTO cookies (id, device_id, domain, cookie_name, path, nonce, ciphertext, lamport_ts, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id, domain, cookie_name, path) DO UPDATE SET + nonce = excluded.nonce, + ciphertext = excluded.ciphertext, + lamport_ts = excluded.lamport_ts, + updated_at = excluded.updated_at + `).run(id, blob.deviceId, blob.domain, blob.cookieName, blob.path, blob.nonce, blob.ciphertext, blob.lamportTs, updatedAt); + + // Enforce per-device limit + const count = (this.db.prepare("SELECT COUNT(*) as cnt FROM cookies WHERE device_id = ?").get(blob.deviceId) as { cnt: number }).cnt; + if (count > MAX_STORED_COOKIES_PER_DEVICE) { + this.db.prepare( + "DELETE FROM cookies WHERE id IN (SELECT id FROM cookies WHERE device_id = ? ORDER BY updated_at ASC LIMIT ?)", + ).run(blob.deviceId, count - MAX_STORED_COOKIES_PER_DEVICE); + } + + return { ...blob, id, updatedAt }; + } + + async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise { + const result = this.db.prepare( + "DELETE FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?", + ).run(deviceId, domain, cookieName, path); + return result.changes > 0; + } + + async deleteById(id: string): Promise { + const result = this.db.prepare("DELETE FROM cookies WHERE id = ?").run(id); + return result.changes > 0; + } + + async getByDevice(deviceId: string, domain?: string): Promise { + if (domain) { + return (this.db.prepare("SELECT * FROM cookies WHERE device_id = ? AND domain = ?").all(deviceId, domain) as Record[]).map(toBlob); + } + return (this.db.prepare("SELECT * FROM cookies WHERE device_id = ?").all(deviceId) as Record[]).map(toBlob); + } + + async getByDevices(deviceIds: string[], domain?: string): Promise { + if (deviceIds.length === 0) return []; + const placeholders = deviceIds.map(() => "?").join(","); + if (domain) { + return (this.db.prepare(`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND domain = ?`).all(...deviceIds, domain) as Record[]).map(toBlob); + } + return (this.db.prepare(`SELECT * FROM cookies WHERE device_id IN (${placeholders})`).all(...deviceIds) as Record[]).map(toBlob); + } + + async getAll(): Promise { + return (this.db.prepare("SELECT * FROM cookies").all() as Record[]).map(toBlob); + } + + async getById(id: string): Promise { + const row = this.db.prepare("SELECT * FROM cookies WHERE id = ?").get(id) as Record | undefined; + return row ? toBlob(row) : null; + } + + async getUpdatedSince(deviceIds: string[], since: string): Promise { + if (deviceIds.length === 0) return []; + const placeholders = deviceIds.map(() => "?").join(","); + return (this.db.prepare( + `SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND updated_at > ?`, + ).all(...deviceIds, since) as Record[]).map(toBlob); + } +} + +// --- SQLite Device Store --- + +class SqliteDeviceStore implements IDeviceStore { + constructor(private db: Database.Database) {} + + async register(deviceId: string, name: string, platform: string, encPub: string): Promise { + const existing = this.db.prepare("SELECT * FROM devices WHERE device_id = ?").get(deviceId) as Record | undefined; + if (existing) return toDevice(existing); + + const token = generateToken(); + const createdAt = new Date().toISOString(); + this.db.prepare( + "INSERT INTO devices (device_id, name, platform, enc_pub, token, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run(deviceId, name, platform, encPub, token, createdAt); + + return { deviceId, name, platform, encPub, token, createdAt }; + } + + async getByToken(token: string): Promise { + const row = this.db.prepare("SELECT * FROM devices WHERE token = ?").get(token) as Record | undefined; + return row ? toDevice(row) : null; + } + + async getById(deviceId: string): Promise { + const row = this.db.prepare("SELECT * FROM devices WHERE device_id = ?").get(deviceId) as Record | undefined; + return row ? toDevice(row) : null; + } + + async addPairing(deviceIdA: string, deviceIdB: string): Promise { + this.db.prepare( + "INSERT OR IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)", + ).run(deviceIdA, deviceIdB); + this.db.prepare( + "INSERT OR IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)", + ).run(deviceIdB, deviceIdA); + } + + async getPairedDevices(deviceId: string): Promise { + const rows = this.db.prepare( + "SELECT device_id_b FROM pairings WHERE device_id_a = ?", + ).all(deviceId) as { device_id_b: string }[]; + return rows.map((r) => r.device_id_b); + } + + async getPairingGroup(deviceId: string): Promise { + const paired = await this.getPairedDevices(deviceId); + return [deviceId, ...paired]; + } + + async listAll(): Promise { + return (this.db.prepare("SELECT * FROM devices").all() as Record[]).map(toDevice); + } + + async revoke(deviceId: string): Promise { + const result = this.db.prepare("DELETE FROM devices WHERE device_id = ?").run(deviceId); + if (result.changes === 0) return false; + this.db.prepare("DELETE FROM pairings WHERE device_id_a = ? OR device_id_b = ?").run(deviceId, deviceId); + return true; + } +} + +// --- SQLite Agent Store --- + +class SqliteAgentStore implements IAgentStore { + constructor(private db: Database.Database) {} + + async create(name: string, encPub: string, allowedDomains: string[]): Promise { + const id = generateId(); + const token = generateToken(); + const createdAt = new Date().toISOString(); + this.db.prepare( + "INSERT INTO agents (id, name, token, enc_pub, allowed_domains, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run(id, name, token, encPub, JSON.stringify(allowedDomains), createdAt); + return { id, name, token, encPub, allowedDomains, createdAt }; + } + + async getByToken(token: string): Promise { + const row = this.db.prepare("SELECT * FROM agents WHERE token = ?").get(token) as Record | undefined; + return row ? toAgent(row) : null; + } + + async grantAccess(agentId: string, deviceId: string): Promise { + this.db.prepare( + "INSERT OR IGNORE INTO agent_device_access (agent_id, device_id) VALUES (?, ?)", + ).run(agentId, deviceId); + } + + async getAccessibleDevices(agentId: string): Promise { + const rows = this.db.prepare( + "SELECT device_id FROM agent_device_access WHERE agent_id = ?", + ).all(agentId) as { device_id: string }[]; + return rows.map((r) => r.device_id); + } + + async revokeAccess(agentId: string, deviceId: string): Promise { + this.db.prepare( + "DELETE FROM agent_device_access WHERE agent_id = ? AND device_id = ?", + ).run(agentId, deviceId); + } +} + +// --- SQLite Admin Store --- + +class SqliteAdminStore implements IAdminStore { + private jwtSecret: string; + private _settings: AdminSettings; + + constructor(private db: Database.Database) { + // Load or generate JWT secret + const meta = this.db.prepare("SELECT value FROM admin_meta WHERE key = 'jwt_secret'").get() as { value: string } | undefined; + if (meta) { + this.jwtSecret = meta.value; + } else { + this.jwtSecret = crypto.randomBytes(32).toString("hex"); + this.db.prepare("INSERT INTO admin_meta (key, value) VALUES ('jwt_secret', ?)").run(this.jwtSecret); + } + + // Load settings + this._settings = { ...DEFAULT_SETTINGS }; + const settingsRow = this.db.prepare("SELECT value FROM admin_settings WHERE key = 'settings'").get() as { value: string } | undefined; + if (settingsRow) { + Object.assign(this._settings, JSON.parse(settingsRow.value)); + } + } + + get isSetUp(): boolean { + const row = this.db.prepare("SELECT COUNT(*) as cnt FROM admin_users").get() as { cnt: number }; + return row.cnt > 0; + } + + async setup(username: string, password: string): Promise { + if (this.isSetUp) throw new Error("Already configured"); + const salt = crypto.randomBytes(16).toString("hex"); + const hash = await hashPassword(password, salt); + this.db.prepare( + "INSERT INTO admin_users (username, password_hash, salt, created_at) VALUES (?, ?, ?, ?)", + ).run(username, hash, salt, new Date().toISOString()); + } + + async login(username: string, password: string): Promise { + const user = this.db.prepare("SELECT * FROM admin_users WHERE username = ?").get(username) as Record | undefined; + if (!user) throw new Error("Invalid credentials"); + const hash = await hashPassword(password, user.salt as string); + if (hash !== user.password_hash) throw new Error("Invalid credentials"); + return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" }); + } + + verifyToken(token: string): { sub: string; role: string } { + return jwt.verify(token, this.jwtSecret) as { sub: string; role: string }; + } + + getUser(): { username: string; createdAt: string } | null { + const row = this.db.prepare("SELECT username, created_at FROM admin_users LIMIT 1").get() as Record | undefined; + if (!row) return null; + return { username: row.username as string, createdAt: row.created_at as string }; + } + + getSettings(): AdminSettings { + return { ...this._settings }; + } + + updateSettings(patch: Partial): AdminSettings { + Object.assign(this._settings, patch); + this.db.prepare( + "INSERT INTO admin_settings (key, value) VALUES ('settings', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ).run(JSON.stringify(this._settings)); + return { ...this._settings }; + } +} + +// --- Factory --- + +export function createSqliteStores(config: SqliteConfig): DataStores { + const db = new Database(config.path); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(db); + + return { + cookieStore: new SqliteCookieStore(db), + deviceStore: new SqliteDeviceStore(db), + agentStore: new SqliteAgentStore(db), + adminStore: new SqliteAdminStore(db), + async close() { + db.close(); + }, + }; +} diff --git a/src/relay/db/types.ts b/src/relay/db/types.ts new file mode 100644 index 0000000..b6235a9 --- /dev/null +++ b/src/relay/db/types.ts @@ -0,0 +1,72 @@ +import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js"; +import type { AdminSettings, AdminUser } from "../admin/auth.js"; + +// --- Database configuration --- + +export type DbType = "sqlite" | "mysql"; + +export interface SqliteConfig { + type: "sqlite"; + path: string; // file path, e.g. "./data/cookiebridge.db" +} + +export interface MysqlConfig { + type: "mysql"; + host: string; + port: number; + user: string; + password: string; + database: string; +} + +export type DbConfig = SqliteConfig | MysqlConfig; + +// --- Store interfaces --- + +export interface ICookieStore { + upsert(blob: Omit): Promise; + delete(deviceId: string, domain: string, cookieName: string, path: string): Promise; + deleteById(id: string): Promise; + getByDevice(deviceId: string, domain?: string): Promise; + getByDevices(deviceIds: string[], domain?: string): Promise; + getAll(): Promise; + getById(id: string): Promise; + getUpdatedSince(deviceIds: string[], since: string): Promise; +} + +export interface IDeviceStore { + register(deviceId: string, name: string, platform: string, encPub: string): Promise; + getByToken(token: string): Promise; + getById(deviceId: string): Promise; + addPairing(deviceIdA: string, deviceIdB: string): Promise; + getPairedDevices(deviceId: string): Promise; + getPairingGroup(deviceId: string): Promise; + listAll(): Promise; + revoke(deviceId: string): Promise; +} + +export interface IAgentStore { + create(name: string, encPub: string, allowedDomains: string[]): Promise; + getByToken(token: string): Promise; + grantAccess(agentId: string, deviceId: string): Promise; + getAccessibleDevices(agentId: string): Promise; + revokeAccess(agentId: string, deviceId: string): Promise; +} + +export interface IAdminStore { + readonly isSetUp: boolean; + setup(username: string, password: string): Promise; + login(username: string, password: string): Promise; + verifyToken(token: string): { sub: string; role: string }; + getUser(): { username: string; createdAt: string } | null; + getSettings(): AdminSettings; + updateSettings(patch: Partial): AdminSettings; +} + +export interface DataStores { + cookieStore: ICookieStore; + deviceStore: IDeviceStore; + agentStore: IAgentStore; + adminStore: IAdminStore; + close(): Promise; +} diff --git a/src/relay/index.ts b/src/relay/index.ts index 2fd930e..0b40acf 100644 --- a/src/relay/index.ts +++ b/src/relay/index.ts @@ -3,3 +3,5 @@ export type { RelayServerConfig } from "./server.js"; export { ConnectionManager } from "./connections.js"; export { CookieBlobStore } from "./store.js"; export { DeviceRegistry, AgentRegistry } from "./tokens.js"; +export type { DataStores, DbConfig, DbType, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/index.js"; +export { createStores, createMemoryStores, loadDbConfig, saveDbConfig } from "./db/index.js"; diff --git a/src/relay/server.ts b/src/relay/server.ts index 405519b..6cdb381 100644 --- a/src/relay/server.ts +++ b/src/relay/server.ts @@ -4,20 +4,20 @@ import { ConnectionManager } from "./connections.js"; import { generateChallenge, verifyAuthResponse } from "./auth.js"; import { verify, buildSignablePayload } from "../crypto/signing.js"; import { PairingStore } from "../pairing/pairing.js"; -import { CookieBlobStore } from "./store.js"; -import { DeviceRegistry, AgentRegistry } from "./tokens.js"; import { type Envelope, type EncryptedCookieBlob, MESSAGE_TYPES, PING_INTERVAL_MS, } from "../protocol/spec.js"; -import { AdminStore } from "./admin/auth.js"; import { handleAdminRoute } from "./admin/routes.js"; +import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js"; +import { createMemoryStores } from "./db/memory.js"; export interface RelayServerConfig { port: number; host?: string; + stores?: DataStores; } interface PendingAuth { @@ -49,10 +49,11 @@ export class RelayServer { private wss: WebSocketServer; readonly connections: ConnectionManager; readonly pairingStore: PairingStore; - readonly cookieStore: CookieBlobStore; - readonly deviceRegistry: DeviceRegistry; - readonly agentRegistry: AgentRegistry; - readonly adminStore: AdminStore; + cookieStore: ICookieStore; + deviceRegistry: IDeviceStore; + agentRegistry: IAgentStore; + adminStore: IAdminStore; + private stores: DataStores; private pendingAuths = new Map(); private authenticatedDevices = new Map(); private pingIntervals = new Map>(); @@ -60,16 +61,27 @@ export class RelayServer { constructor(private config: RelayServerConfig) { this.connections = new ConnectionManager(); this.pairingStore = new PairingStore(); - this.cookieStore = new CookieBlobStore(); - this.deviceRegistry = new DeviceRegistry(); - this.agentRegistry = new AgentRegistry(); - this.adminStore = new AdminStore(); + + this.stores = config.stores ?? createMemoryStores(); + this.cookieStore = this.stores.cookieStore; + this.deviceRegistry = this.stores.deviceStore; + this.agentRegistry = this.stores.agentStore; + this.adminStore = this.stores.adminStore; this.httpServer = http.createServer(this.handleHttp.bind(this)); this.wss = new WebSocketServer({ server: this.httpServer }); this.wss.on("connection", this.handleConnection.bind(this)); } + /** Replace the data stores at runtime (used during setup when DB is first configured). */ + replaceStores(stores: DataStores): void { + this.stores = stores; + this.cookieStore = stores.cookieStore; + this.deviceRegistry = stores.deviceStore; + this.agentRegistry = stores.agentStore; + this.adminStore = stores.adminStore; + } + start(): Promise { return new Promise((resolve) => { this.httpServer.listen( @@ -80,15 +92,16 @@ export class RelayServer { }); } - stop(): Promise { - return new Promise((resolve) => { - for (const interval of this.pingIntervals.values()) { - clearInterval(interval); - } + async stop(): Promise { + for (const interval of this.pingIntervals.values()) { + clearInterval(interval); + } + await new Promise((resolve) => { this.wss.close(() => { this.httpServer.close(() => resolve()); }); }); + await this.stores.close(); } get port(): number { @@ -110,6 +123,7 @@ export class RelayServer { connections: this.connections, cookieStore: this.cookieStore, deviceRegistry: this.deviceRegistry, + server: this, }); return; } @@ -184,12 +198,13 @@ export class RelayServer { this.json(res, 401, { error: "Missing Authorization header" }); return; } - const device = this.deviceRegistry.getByToken(token); - if (!device) { - this.json(res, 401, { error: "Invalid token" }); - return; - } - handler({ deviceId: device.deviceId }); + this.deviceRegistry.getByToken(token).then((device) => { + if (!device) { + this.json(res, 401, { error: "Invalid token" }); + return; + } + handler({ deviceId: device.deviceId }); + }); } private extractBearerToken(req: http.IncomingMessage): string | null { @@ -201,14 +216,14 @@ export class RelayServer { // --- Device Registration --- private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void { - this.readBody(req, (body) => { + this.readBody(req, async (body) => { try { const { deviceId, name, platform, encPub } = JSON.parse(body); if (!deviceId || !name || !platform || !encPub) { this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" }); return; } - const info = this.deviceRegistry.register(deviceId, name, platform, encPub); + const info = await this.deviceRegistry.register(deviceId, name, platform, encPub); this.json(res, 201, { deviceId: info.deviceId, token: info.token, @@ -239,7 +254,7 @@ export class RelayServer { } private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void { - this.readBody(req, (body) => { + this.readBody(req, async (body) => { try { const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body); if (!deviceId || !x25519PubKey || !pairingCode) { @@ -253,7 +268,7 @@ export class RelayServer { } // Record the pairing in device registry - this.deviceRegistry.addPairing(session.deviceId, deviceId); + await this.deviceRegistry.addPairing(session.deviceId, deviceId); this.json(res, 200, { initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey }, @@ -272,7 +287,7 @@ export class RelayServer { res: http.ServerResponse, device: { deviceId: string }, ): void { - this.readBody(req, (body) => { + this.readBody(req, async (body) => { try { const { cookies } = JSON.parse(body) as { cookies: Array>; @@ -282,12 +297,13 @@ export class RelayServer { return; } - const stored = cookies.map((c) => - this.cookieStore.upsert({ ...c, deviceId: device.deviceId }), - ); + const stored: EncryptedCookieBlob[] = []; + for (const c of cookies) { + stored.push(await this.cookieStore.upsert({ ...c, deviceId: device.deviceId })); + } // Notify paired devices via WebSocket if connected - const pairedDevices = this.deviceRegistry.getPairedDevices(device.deviceId); + const pairedDevices = await this.deviceRegistry.getPairedDevices(device.deviceId); for (const peerId of pairedDevices) { if (this.connections.isOnline(peerId)) { this.connections.send(peerId, { @@ -309,26 +325,26 @@ export class RelayServer { }); } - private handleCookiePull( + private async handleCookiePull( _req: http.IncomingMessage, res: http.ServerResponse, device: { deviceId: string }, - ): void { + ): Promise { const url = new URL(_req.url ?? "", `http://${_req.headers.host}`); const domain = url.searchParams.get("domain") ?? undefined; // Get cookies from all paired devices - const group = this.deviceRegistry.getPairingGroup(device.deviceId); - const blobs = this.cookieStore.getByDevices(group, domain); + const group = await this.deviceRegistry.getPairingGroup(device.deviceId); + const blobs = await this.cookieStore.getByDevices(group, domain); this.json(res, 200, { cookies: blobs }); } - private handleCookiePoll( + private async handleCookiePoll( _req: http.IncomingMessage, res: http.ServerResponse, device: { deviceId: string }, - ): void { + ): Promise { const url = new URL(_req.url ?? "", `http://${_req.headers.host}`); const since = url.searchParams.get("since"); if (!since) { @@ -336,8 +352,8 @@ export class RelayServer { return; } - const group = this.deviceRegistry.getPairingGroup(device.deviceId); - const blobs = this.cookieStore.getUpdatedSince(group, since); + const group = await this.deviceRegistry.getPairingGroup(device.deviceId); + const blobs = await this.cookieStore.getUpdatedSince(group, since); this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() }); } @@ -347,14 +363,14 @@ export class RelayServer { res: http.ServerResponse, device: { deviceId: string }, ): void { - this.readBody(req, (body) => { + this.readBody(req, async (body) => { try { const { domain, cookieName, path } = JSON.parse(body); if (!domain || !cookieName || !path) { this.json(res, 400, { error: "Missing domain, cookieName, or path" }); return; } - const deleted = this.cookieStore.delete(device.deviceId, domain, cookieName, path); + const deleted = await this.cookieStore.delete(device.deviceId, domain, cookieName, path); this.json(res, 200, { deleted }); } catch { this.json(res, 400, { error: "Invalid JSON" }); @@ -369,22 +385,22 @@ export class RelayServer { res: http.ServerResponse, _device: { deviceId: string }, ): void { - this.readBody(req, (body) => { + this.readBody(req, async (body) => { try { const { name, encPub, allowedDomains } = JSON.parse(body); if (!name || !encPub) { this.json(res, 400, { error: "Missing name or encPub" }); return; } - const agent = this.agentRegistry.create(name, encPub, allowedDomains ?? []); + const agent = await this.agentRegistry.create(name, encPub, allowedDomains ?? []); // Automatically grant the creating device's access - this.agentRegistry.grantAccess(agent.id, _device.deviceId); + await this.agentRegistry.grantAccess(agent.id, _device.deviceId); // Also grant access to all paired devices - const paired = this.deviceRegistry.getPairedDevices(_device.deviceId); + const paired = await this.deviceRegistry.getPairedDevices(_device.deviceId); for (const peerId of paired) { - this.agentRegistry.grantAccess(agent.id, peerId); + await this.agentRegistry.grantAccess(agent.id, peerId); } this.json(res, 201, { id: agent.id, token: agent.token, name: agent.name }); @@ -399,14 +415,14 @@ export class RelayServer { res: http.ServerResponse, device: { deviceId: string }, ): void { - this.readBody(req, (body) => { + this.readBody(req, async (body) => { try { const { agentId } = JSON.parse(body); if (!agentId) { this.json(res, 400, { error: "Missing agentId" }); return; } - this.agentRegistry.grantAccess(agentId, device.deviceId); + await this.agentRegistry.grantAccess(agentId, device.deviceId); this.json(res, 200, { granted: true }); } catch { this.json(res, 400, { error: "Invalid JSON" }); @@ -414,13 +430,13 @@ export class RelayServer { }); } - private handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): void { + private async handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): Promise { const token = this.extractBearerToken(req); if (!token) { this.json(res, 401, { error: "Missing Authorization header" }); return; } - const agent = this.agentRegistry.getByToken(token); + const agent = await this.agentRegistry.getByToken(token); if (!agent) { this.json(res, 401, { error: "Invalid agent token" }); return; @@ -435,8 +451,8 @@ export class RelayServer { return; } - const deviceIds = this.agentRegistry.getAccessibleDevices(agent.id); - const blobs = this.cookieStore.getByDevices(deviceIds, domain); + const deviceIds = await this.agentRegistry.getAccessibleDevices(agent.id); + const blobs = await this.cookieStore.getByDevices(deviceIds, domain); this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub }); } @@ -510,23 +526,24 @@ export class RelayServer { return; } - const device = this.deviceRegistry.getByToken(token); - if (!device) { - ws.close(4003, "Invalid token"); - return; - } - - this.pendingAuths.delete(ws); - this.authenticatedDevices.set(ws, device.deviceId); - this.connections.register(device.deviceId, ws); - ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId })); - - const interval = setInterval(() => { - if (ws.readyState === 1) { - ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING })); + this.deviceRegistry.getByToken(token).then((device) => { + if (!device) { + ws.close(4003, "Invalid token"); + return; } - }, PING_INTERVAL_MS); - this.pingIntervals.set(ws, interval); + + this.pendingAuths.delete(ws); + this.authenticatedDevices.set(ws, device.deviceId); + this.connections.register(device.deviceId, ws); + ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId })); + + const interval = setInterval(() => { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING })); + } + }, PING_INTERVAL_MS); + this.pingIntervals.set(ws, interval); + }); } private handleAuthResponse(ws: WebSocket, msg: Record): void { diff --git a/web/src/views/SetupView.vue b/web/src/views/SetupView.vue index c2f8277..d1802b1 100644 --- a/web/src/views/SetupView.vue +++ b/web/src/views/SetupView.vue @@ -7,14 +7,24 @@ import { markSetupComplete } from "@/router"; const router = useRouter(); const step = ref(1); -const totalSteps = 4; +const totalSteps = 5; -// Step 2: Admin account +// Step 2: Database config +type DbType = "sqlite" | "mysql"; +const dbType = ref("sqlite"); +const sqlitePath = ref("./data/cookiebridge.db"); +const mysqlHost = ref("localhost"); +const mysqlPort = ref(3306); +const mysqlUser = ref("root"); +const mysqlPassword = ref(""); +const mysqlDatabase = ref("cookiebridge"); + +// Step 3: Admin account const username = ref(""); const password = ref(""); const confirmPassword = ref(""); -// Step 3: Basic config +// Step 4: Basic config const listenPort = ref(8100); const enableHttps = ref(false); @@ -25,16 +35,28 @@ const passwordMismatch = computed( () => confirmPassword.value.length > 0 && password.value !== confirmPassword.value, ); -const canProceedStep2 = computed( +const canProceedStep3 = computed( () => username.value.length >= 3 && password.value.length >= 8 && password.value === confirmPassword.value, ); +const canProceedStep2 = computed(() => { + if (dbType.value === "sqlite") { + return sqlitePath.value.length > 0; + } + return ( + mysqlHost.value.length > 0 && + mysqlPort.value > 0 && + mysqlUser.value.length > 0 && + mysqlDatabase.value.length > 0 + ); +}); + function nextStep() { error.value = ""; - if (step.value === 2 && passwordMismatch.value) { + if (step.value === 3 && passwordMismatch.value) { error.value = "Passwords do not match"; return; } @@ -46,6 +68,20 @@ function prevStep() { step.value = Math.max(step.value - 1, 1); } +function buildDbConfig() { + if (dbType.value === "sqlite") { + return { type: "sqlite" as const, path: sqlitePath.value }; + } + return { + type: "mysql" as const, + host: mysqlHost.value, + port: mysqlPort.value, + user: mysqlUser.value, + password: mysqlPassword.value, + database: mysqlDatabase.value, + }; +} + async function completeSetup() { error.value = ""; loading.value = true; @@ -53,11 +89,13 @@ async function completeSetup() { await api.post("/setup/init", { username: username.value, password: password.value, + dbConfig: buildDbConfig(), }); markSetupComplete(); step.value = totalSteps; - } catch { - error.value = "Setup failed. Please try again."; + } catch (e: unknown) { + const axiosError = e as { response?: { data?: { error?: string } } }; + error.value = axiosError.response?.data?.error ?? "Setup failed. Please try again."; } finally { loading.value = false; } @@ -110,8 +148,143 @@ function goToLogin() { - +
+

Database Configuration

+

Choose how to store your data

+ +
+ +
+ + +
+ + +
+ + +

+ File will be created automatically. Relative paths are from the server directory. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +

{{ error }}

+ +
+ + +
+
+
+ + +

Create Admin Account

Set up your administrator credentials

@@ -178,7 +351,7 @@ function goToLogin() {
- -
+ +

Basic Configuration

Configure your relay server

@@ -241,14 +414,14 @@ function goToLogin() { class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" @click="completeSetup" > - {{ loading ? "Setting up..." : "Next" }} + {{ loading ? "Setting up..." : "Complete Setup" }}
- -
+ +
@@ -256,6 +429,9 @@ function goToLogin() {

Your CookieBridge server is ready. Sign in with your admin credentials.

+

+ Database: {{ dbType === 'sqlite' ? 'SQLite' : 'MySQL' }} +