feat: add JWT auth with db-backed users
Some checks failed
Build & Release / build (push) Has been cancelled
Some checks failed
Build & Release / build (push) Has been cancelled
- users table created on startup via migrate() - POST /api/auth/setup to create first user (blocked once any user exists) - POST /api/auth/login returns httpOnly JWT cookie (7d expiry) - POST /api/auth/logout clears cookie - GET /api/auth/me for auth check - All /api/customers routes require valid JWT - Frontend shows login form when unauthenticated - Fix type errors in k8s, do, and pgbouncer services
This commit is contained in:
28
.gitea/workflows/ci.yml
Normal file
28
.gitea/workflows/ci.yml
Normal file
@@ -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
|
||||
34
bun.lock
34
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=="],
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LunarFront Manager</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Provision Customer</h2>
|
||||
<label for="name">Customer Slug</label>
|
||||
<input id="name" type="text" placeholder="acme-shop" pattern="[a-z0-9-]+" />
|
||||
<button id="provision-btn" onclick="provision()">Provision</button>
|
||||
<div id="provision-status" class="status"></div>
|
||||
<div id="login-view">
|
||||
<h1>LunarFront Manager</h1>
|
||||
<div class="card">
|
||||
<h2>Sign In</h2>
|
||||
<label for="login-username">Username</label>
|
||||
<input id="login-username" type="text" autocomplete="username" />
|
||||
<label for="login-password">Password</label>
|
||||
<input id="login-password" type="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()" />
|
||||
<button onclick="login()">Sign In</button>
|
||||
<div id="login-status" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app-view">
|
||||
<div class="header">
|
||||
<h1>LunarFront Manager</h1>
|
||||
<button class="logout-btn" onclick="logout()">Sign Out</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Provision Customer</h2>
|
||||
<label for="name">Customer Slug</label>
|
||||
<input id="name" type="text" placeholder="acme-shop" pattern="[a-z0-9-]+" />
|
||||
<button id="provision-btn" onclick="provision()">Provision</button>
|
||||
<div id="provision-status" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function checkAuth() {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
document.getElementById('app-view').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('login-view').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const status = document.getElementById('login-status');
|
||||
|
||||
status.className = 'status';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? 'Login failed');
|
||||
document.getElementById('login-view').style.display = 'none';
|
||||
document.getElementById('app-view').style.display = 'block';
|
||||
} catch (err) {
|
||||
status.textContent = err.message;
|
||||
status.className = 'status error';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
document.getElementById('app-view').style.display = 'none';
|
||||
document.getElementById('login-view').style.display = 'block';
|
||||
document.getElementById('login-password').value = '';
|
||||
}
|
||||
|
||||
async function provision() {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const btn = document.getElementById('provision-btn');
|
||||
@@ -63,6 +125,8 @@
|
||||
btn.textContent = 'Provision';
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
src/db/manager.ts
Normal file
15
src/db/manager.ts
Normal file
@@ -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()
|
||||
)
|
||||
`;
|
||||
}
|
||||
38
src/index.ts
38
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<void>;
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -30,9 +30,9 @@ async function k8sFetch(path: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export async function getSecret(namespace: string, name: string): Promise<Record<string, string>> {
|
||||
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`);
|
||||
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`) as { data: Record<string, string> };
|
||||
return Object.fromEntries(
|
||||
Object.entries(secret.data as Record<string, string>).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<Record<string, string>> {
|
||||
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`);
|
||||
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`) as { data?: Record<string, string> };
|
||||
return cm.data ?? {};
|
||||
}
|
||||
|
||||
|
||||
70
src/routes/auth.ts
Normal file
70
src/routes/auth.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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<string> {
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}`);
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}`) as { database: { connection: { host: string } } };
|
||||
return res.database.connection.host;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user