diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..768b730 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bun run typecheck + + - name: Test + run: bun test diff --git a/bun.lock b/bun.lock index 834d1df..ae1cabe 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "lunarfront-manager", "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/jwt": "^10.0.0", "@fastify/static": "^9.0.0", "fastify": "^5.8.4", "postgres": "^3.4.8", @@ -24,12 +26,16 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + "@fastify/cookie": ["@fastify/cookie@11.0.2", "", { "dependencies": { "cookie": "^1.0.0", "fastify-plugin": "^5.0.0" } }, "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + "@fastify/jwt": ["@fastify/jwt@10.0.0", "", { "dependencies": { "@fastify/error": "^4.2.0", "@lukeed/ms": "^2.0.2", "fast-jwt": "^6.0.2", "fastify-plugin": "^5.0.1", "steed": "^1.1.3" } }, "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA=="], + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], @@ -52,12 +58,16 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], @@ -70,6 +80,8 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -78,16 +90,24 @@ "fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="], + "fast-jwt": ["fast-jwt@6.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "asn1.js": "^5.4.1", "ecdsa-sig-formatter": "^1.0.11", "mnemonist": "^0.40.0" } }, "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ=="], + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fastfall": ["fastfall@1.5.1", "", { "dependencies": { "reusify": "^1.0.0" } }, "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q=="], + "fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="], "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + "fastparallel": ["fastparallel@2.4.1", "", { "dependencies": { "reusify": "^1.0.4", "xtend": "^4.0.2" } }, "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fastseries": ["fastseries@1.7.2", "", { "dependencies": { "reusify": "^1.0.0", "xtend": "^4.0.0" } }, "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ=="], + "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -108,10 +128,16 @@ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mnemonist": ["mnemonist@0.40.3", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ=="], + + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], @@ -138,10 +164,14 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -156,6 +186,8 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "steed": ["steed@1.1.3", "", { "dependencies": { "fastfall": "^1.5.0", "fastparallel": "^2.2.0", "fastq": "^1.3.0", "fastseries": "^1.7.0", "reusify": "^1.0.0" } }, "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA=="], + "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], @@ -166,6 +198,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], diff --git a/frontend/index.html b/frontend/index.html index ca2b7b6..27ca860 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,20 +19,82 @@ .status { margin-top: 1rem; font-size: 0.85rem; padding: 0.6rem 0.8rem; border-radius: 6px; display: none; } .status.success { background: #14532d; color: #86efac; display: block; } .status.error { background: #450a0a; color: #fca5a5; display: block; } + .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; } + .logout-btn { background: none; border: 1px solid #333; color: #aaa; font-size: 0.8rem; padding: 0.4rem 0.8rem; } + .logout-btn:hover { background: #1a1a1a; color: #fff; } + #login-view, #app-view { display: none; } -

LunarFront Manager

-
-

Provision Customer

- - - -
+
+

LunarFront Manager

+
+

Sign In

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

LunarFront Manager

+ +
+ +
+

Provision Customer

+ + + +
+
diff --git a/package.json b/package.json index fdd665c..b6c3066 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "lunarfront-manager", "scripts": { "dev": "bun run --watch src/index.ts", - "start": "bun run src/index.ts" + "start": "bun run src/index.ts", + "typecheck": "tsc --noEmit" }, "module": "index.ts", "type": "module", @@ -15,6 +16,8 @@ "typescript": "^5" }, "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/jwt": "^10.0.0", "@fastify/static": "^9.0.0", "fastify": "^5.8.4", "postgres": "^3.4.8", diff --git a/src/db/manager.ts b/src/db/manager.ts new file mode 100644 index 0000000..1055ca5 --- /dev/null +++ b/src/db/manager.ts @@ -0,0 +1,15 @@ +import postgres from "postgres"; +import { config } from "../lib/config"; + +export const db = postgres(config.dbUrl); + +export async function migrate() { + await db` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} diff --git a/src/index.ts b/src/index.ts index cdad67d..9bd2e25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,35 @@ -import Fastify from "fastify"; +import Fastify, { type FastifyRequest, type FastifyReply } from "fastify"; +import jwtPlugin from "@fastify/jwt"; +import cookiePlugin from "@fastify/cookie"; + +declare module "fastify" { + interface FastifyInstance { + authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise; + } +} import staticFiles from "@fastify/static"; import { join } from "path"; +import { config } from "./lib/config"; +import { migrate } from "./db/manager"; +import { authRoutes } from "./routes/auth"; import { customerRoutes } from "./routes/customers"; const app = Fastify({ logger: true }); +await app.register(cookiePlugin); +await app.register(jwtPlugin, { + secret: config.jwtSecret, + cookie: { cookieName: "token", signed: false }, +}); + +app.decorate("authenticate", async function (req: any, reply: any) { + try { + await req.jwtVerify({ onlyCookie: true }); + } catch { + reply.status(401).send({ message: "Unauthorized" }); + } +}); + app.register(staticFiles, { root: join(import.meta.dir, "../frontend"), prefix: "/", @@ -12,9 +37,16 @@ app.register(staticFiles, { app.get("/health", async () => ({ status: "ok" })); -app.register(customerRoutes, { prefix: "/api" }); +app.register(authRoutes, { prefix: "/api" }); -app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" }, (err) => { +app.register(customerRoutes, { + prefix: "/api", + onRequest: [app.authenticate], +} as any); + +await migrate(); + +app.listen({ port: config.port, host: "0.0.0.0" }, (err) => { if (err) { app.log.error(err); process.exit(1); diff --git a/src/lib/config.ts b/src/lib/config.ts index c2d6699..fc56e1e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -6,6 +6,7 @@ export const config = { gitRepoUrl: process.env.GIT_REPO_URL ?? "ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git", dbUrl: process.env.DATABASE_URL!, doadminDbUrl: process.env.DOADMIN_DATABASE_URL!, + jwtSecret: process.env.JWT_SECRET!, }; for (const [key, val] of Object.entries(config)) { diff --git a/src/lib/k8s.ts b/src/lib/k8s.ts index 2a73af4..74d2cc1 100644 --- a/src/lib/k8s.ts +++ b/src/lib/k8s.ts @@ -30,9 +30,9 @@ async function k8sFetch(path: string, options: RequestInit = {}) { } export async function getSecret(namespace: string, name: string): Promise> { - const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`); + const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`) as { data: Record }; return Object.fromEntries( - Object.entries(secret.data as Record).map(([k, v]) => [k, Buffer.from(v, "base64").toString()]) + Object.entries(secret.data).map(([k, v]) => [k, Buffer.from(v, "base64").toString()]) ); } @@ -56,7 +56,7 @@ export async function patchConfigMap(namespace: string, name: string, data: Reco } export async function getConfigMap(namespace: string, name: string): Promise> { - const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`); + const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`) as { data?: Record }; return cm.data ?? {}; } diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..ca3ff37 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { z } from "zod"; +import { db } from "../db/manager"; + +declare module "fastify" { + interface FastifyInstance { + authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +const LoginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +const SetupSchema = z.object({ + username: z.string().min(3).max(32).regex(/^[a-z0-9_]+$/), + password: z.string().min(12), +}); + +export async function authRoutes(app: FastifyInstance) { + app.post("/auth/setup", async (req, reply) => { + const [row] = await db`SELECT COUNT(*)::int AS count FROM users` as [{ count: number }]; + if (row.count > 0) { + return reply.status(403).send({ message: "Setup already complete" }); + } + + const body = SetupSchema.parse(req.body); + const hash = await Bun.password.hash(body.password); + const [user] = await db` + INSERT INTO users (username, password_hash) VALUES (${body.username}, ${hash}) RETURNING id, username + ` as [{ id: number; username: string }]; + return { username: user.username }; + }); + + app.post("/auth/login", async (req, reply) => { + const body = LoginSchema.parse(req.body); + + const [user] = await db`SELECT * FROM users WHERE username = ${body.username} LIMIT 1` as [{ id: number; username: string; password_hash: string } | undefined]; + if (!user) { + return reply.status(401).send({ message: "Invalid credentials" }); + } + + const valid = await Bun.password.verify(body.password, user.password_hash); + if (!valid) { + return reply.status(401).send({ message: "Invalid credentials" }); + } + + const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: "7d" }); + + reply.setCookie("token", token, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }); + + return { username: user.username }; + }); + + app.post("/auth/logout", async (_req, reply) => { + reply.clearCookie("token", { path: "/" }); + return { ok: true }; + }); + + app.get("/auth/me", { onRequest: [app.authenticate] }, async (req) => { + return { username: (req.user as { username: string }).username }; + }); +} diff --git a/src/services/do.ts b/src/services/do.ts index e951925..50d0b82 100644 --- a/src/services/do.ts +++ b/src/services/do.ts @@ -30,7 +30,7 @@ export async function createDatabaseUser(name: string): Promise<{ name: string; const res = await doFetch(`/databases/${config.doDbClusterId}/users`, { method: "POST", body: JSON.stringify({ name }), - }); + }) as { user: { name: string; password: string } }; return res.user; } @@ -43,6 +43,6 @@ export async function deleteDatabase(name: string) { } export async function getDatabaseHost(): Promise { - const res = await doFetch(`/databases/${config.doDbClusterId}`); + const res = await doFetch(`/databases/${config.doDbClusterId}`) as { database: { connection: { host: string } } }; return res.database.connection.host; } diff --git a/src/services/pgbouncer.ts b/src/services/pgbouncer.ts index ceffb21..cfe6b00 100644 --- a/src/services/pgbouncer.ts +++ b/src/services/pgbouncer.ts @@ -22,7 +22,7 @@ export async function removeCustomerFromPool(slug: string) { async function addDbEntry(slug: string) { const cm = await getConfigMap(NAMESPACE, "pgbouncer-config"); - const ini = cm["pgbouncer.ini"]; + const ini = cm["pgbouncer.ini"] ?? ""; const newLine = ` ${slug} = host=${DO_HOST} port=${DO_PORT} dbname=${slug} user=${slug} pool_mode=session pool_size=3`; const updated = ini.replace("[pgbouncer]", `${newLine}\n [pgbouncer]`); await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated }); @@ -30,21 +30,21 @@ async function addDbEntry(slug: string) { async function removeDbEntry(slug: string) { const cm = await getConfigMap(NAMESPACE, "pgbouncer-config"); - const ini = cm["pgbouncer.ini"]; + const ini = cm["pgbouncer.ini"] ?? ""; const updated = ini.split("\n").filter((l) => !l.includes(`dbname=${slug}`)).join("\n"); await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated }); } async function addUserEntry(slug: string, password: string) { const secret = await getSecret(NAMESPACE, "pgbouncer-userlist"); - const userlist = secret["userlist.txt"]; + const userlist = secret["userlist.txt"] ?? ""; const updated = `${userlist}\n"${slug}" "${password}"`; await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated }); } async function removeUserEntry(slug: string) { const secret = await getSecret(NAMESPACE, "pgbouncer-userlist"); - const userlist = secret["userlist.txt"]; + const userlist = secret["userlist.txt"] ?? ""; const updated = userlist.split("\n").filter((l) => !l.startsWith(`"${slug}"`)).join("\n"); await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated }); }