feat: implement foundation

This commit is contained in:
Edqe14 2026-05-14 21:12:46 +07:00
parent b08546294a
commit 8a8420c4e3
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
44 changed files with 2377 additions and 27 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
.output
drizzle
.env.local
.env
.playground
docs

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=wahoo
POSTGRES_PASSWORD=wahoo
POSTGRES_DB=wahoo
# Redis
REDIS_URL=redis://localhost:6379
# Encryption — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
MASTER_ENCRYPTION_KEY=
# Session — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
SESSION_SECRET=
# Server
PORT=3000
NODE_ENV=development
# Optional: Caddy HTTPS
DOMAIN=example.com

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ dist-ssr
__unconfig*
todos.json
.playground
test-results

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM oven/bun:1-alpine AS builder
RUN apk add --no-cache python3 make g++ linux-headers
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun --bun run build
# ---- runtime ----
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/.output .output
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View file

@ -15,20 +15,27 @@
"@tanstack/react-start": "latest",
"@tanstack/react-table": "latest",
"@tanstack/router-plugin": "^1.132.0",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"ioredis": "^5.10.1",
"iron-session": "^8.0.4",
"lucide-react": "^0.577.0",
"nitro": "npm:nitro-nightly@latest",
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6",
"uuid": "^14.0.0",
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/eslint-config": "latest",
@ -38,6 +45,7 @@
"@types/pg": "^8.15.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.3.1",
"eslint": "^9.20.0",
@ -123,6 +131,8 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@ -211,6 +221,8 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@ -223,6 +235,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
@ -275,6 +289,10 @@
"@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="],
"@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
@ -449,6 +467,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="],
@ -539,6 +559,8 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
@ -583,6 +605,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@ -595,8 +619,12 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="],
@ -625,6 +653,8 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -759,6 +789,12 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@ -823,6 +859,10 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
@ -849,6 +889,10 @@
"nitro": ["nitro-nightly@3.0.1-20260512-093145-0498ce70", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.6", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.3", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-5vIbEn1ORiNIymy9RtRx3AavX4CAQqWNnsh1vOLag/fzsEQRD2wLBhR+ZcMeVTUbQb0mo8fIbMH2NkSeiGu6fw=="],
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -865,6 +909,8 @@
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"otpauth": ["otpauth@9.5.1", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ=="],
"oxc-parser": ["oxc-parser@0.120.0", "", { "dependencies": { "@oxc-project/types": "^0.120.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.120.0", "@oxc-parser/binding-android-arm64": "0.120.0", "@oxc-parser/binding-darwin-arm64": "0.120.0", "@oxc-parser/binding-darwin-x64": "0.120.0", "@oxc-parser/binding-freebsd-x64": "0.120.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", "@oxc-parser/binding-linux-arm64-musl": "0.120.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-musl": "0.120.0", "@oxc-parser/binding-openharmony-arm64": "0.120.0", "@oxc-parser/binding-wasm32-wasi": "0.120.0", "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", "@oxc-parser/binding-win32-x64-msvc": "0.120.0" } }, "sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@ -905,6 +951,10 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
@ -925,6 +975,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"rate-limiter-flexible": ["rate-limiter-flexible@11.1.0", "", {}, "sha512-lyyC0SqKz+dE5JoHZ4JMqdrM3LSZKBxzuAFAyKCYAnmHnPz/Rb6iDquxoL4CMipDXoR0G+QRhOzYWL3JKihbNw=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
@ -933,6 +985,10 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@ -981,6 +1037,8 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@ -1031,6 +1089,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@ -1051,6 +1111,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"vite": ["vite@8.0.13", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
@ -1155,6 +1217,8 @@
"parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],

54
docker-compose.yml Normal file
View file

@ -0,0 +1,54 @@
services:
api:
build: .
ports:
- "3000:3000"
env_file:
- path: .env.local
required: false
- path: .env
required: false
environment:
- POSTGRES_HOST=postgres
- REDIS_URL=redis://redis:6379
- NODE_ENV=production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
postgres:
image: postgres:18-alpine
env_file:
- path: .env.local
required: false
- path: .env
required: false
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:

View file

@ -3,11 +3,18 @@ import { defineConfig } from 'drizzle-kit'
config({ path: ['.env.local', '.env'] })
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL,
host: POSTGRES_HOST ?? 'localhost',
port: Number(POSTGRES_PORT ?? 5432),
user: POSTGRES_USER ?? 'wahoo',
password: POSTGRES_PASSWORD ?? 'wahoo',
database: POSTGRES_DB ?? 'wahoo',
ssl: false,
},
})

View file

@ -0,0 +1,51 @@
CREATE TYPE "public"."actor_type" AS ENUM('user', 'api_key');--> statement-breakpoint
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"key_hash" text NOT NULL,
"key_prefix" text NOT NULL,
"label" text DEFAULT '' NOT NULL,
"revoked_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "apps" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"owner_user_id" uuid NOT NULL,
"dek_encrypted" text NOT NULL,
"max_sessions" text DEFAULT '10' NOT NULL,
"deleted_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_type" "actor_type" NOT NULL,
"actor_id" uuid NOT NULL,
"action" text NOT NULL,
"resource_type" text NOT NULL,
"resource_id" text,
"ip" text,
"user_agent" text,
"diff_before" jsonb,
"diff_after" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"totp_secret" text,
"totp_enabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apps" ADD CONSTRAINT "apps_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_keys_app_id_idx" ON "api_keys" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "audit_log_actor_idx" ON "audit_log" USING btree ("actor_type","actor_id");--> statement-breakpoint
CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action");--> statement-breakpoint
CREATE INDEX "audit_log_created_at_idx" ON "audit_log" USING btree ("created_at");

View file

@ -0,0 +1,382 @@
{
"id": "26e57c54-3075-4308-8de3-493327b47e7f",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_app_id_idx": {
"name": "api_keys_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_keys_app_id_apps_id_fk": {
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.apps": {
"name": "apps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"dek_encrypted": {
"name": "dek_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_sessions": {
"name": "max_sessions",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'10'"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"apps_owner_user_id_users_id_fk": {
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": [
"owner_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_type": {
"name": "actor_type",
"type": "actor_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"diff_before": {
"name": "diff_before",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_after": {
"name": "diff_after",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_log_actor_idx": {
"name": "audit_log_actor_idx",
"columns": [
{
"expression": "actor_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "actor_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_idx": {
"name": "audit_log_action_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_created_at_idx": {
"name": "audit_log_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"totp_secret": {
"name": "totp_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"totp_enabled": {
"name": "totp_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": [
"user",
"api_key"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778765149216,
"tag": "0000_clean_wong",
"breakpoints": true
}
]
}

View file

@ -30,20 +30,27 @@
"@tanstack/react-start": "latest",
"@tanstack/react-table": "latest",
"@tanstack/router-plugin": "^1.132.0",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"ioredis": "^5.10.1",
"iron-session": "^8.0.4",
"lucide-react": "^0.577.0",
"nitro": "npm:nitro-nightly@latest",
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6"
"tw-animate-css": "^1.3.6",
"uuid": "^14.0.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/eslint-config": "latest",
@ -53,6 +60,7 @@
"@types/pg": "^8.15.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.3.1",
"eslint": "^9.20.0",

19
playwright.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30000,
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
headless: true,
channel: 'chrome',
},
projects: [
{
name: 'chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
})

24
scripts/seed-test-user.ts Normal file
View file

@ -0,0 +1,24 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { users } from '../src/db/schema.ts'
import argon2 from 'argon2'
import { config } from 'dotenv'
config({ path: ['.env.local', '.env'] })
const db = drizzle(
`postgresql://${process.env.POSTGRES_USER ?? 'postgres'}:${process.env.POSTGRES_PASSWORD ?? 'postgres'}@${process.env.POSTGRES_HOST ?? 'localhost'}:${process.env.POSTGRES_PORT ?? '5432'}/${process.env.POSTGRES_DB ?? 'wahoo'}`,
{ schema: { users } },
)
const email = 'test@wahoo.local'
const password = 'TestPassword123!'
const passwordHash = await argon2.hash(password, { type: argon2.argon2id })
await db
.insert(users)
.values({ email, passwordHash })
.onConflictDoNothing()
console.log(`Seeded user: ${email} / ${password}`)
process.exit(0)

View file

@ -2,4 +2,14 @@ import { drizzle } from 'drizzle-orm/node-postgres'
import * as schema from './schema.ts'
export const db = drizzle(process.env.DATABASE_URL!, { schema })
function getDbUrl(): string {
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env
const user = POSTGRES_USER ?? 'wahoo'
const pass = POSTGRES_PASSWORD ?? 'wahoo'
const host = POSTGRES_HOST ?? 'localhost'
const port = POSTGRES_PORT ?? '5432'
const db = POSTGRES_DB ?? 'wahoo'
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}`
}
export const db = drizzle(getDbUrl(), { schema })

View file

@ -1,7 +1,85 @@
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
import {
boolean,
index,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
} from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
export const todos = pgTable('todos', {
id: serial().primaryKey(),
title: text().notNull(),
createdAt: timestamp('created_at').defaultNow(),
export const actorTypeEnum = pgEnum('actor_type', ['user', 'api_key'])
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
totpSecret: text('totp_secret'),
totpEnabled: boolean('totp_enabled').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const apps = pgTable('apps', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
ownerUserId: uuid('owner_user_id')
.notNull()
.references(() => users.id),
dekEncrypted: text('dek_encrypted').notNull(),
maxSessions: text('max_sessions').notNull().default('10'),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const apiKeys = pgTable(
'api_keys',
{
id: uuid('id').primaryKey().defaultRandom(),
appId: uuid('app_id')
.notNull()
.references(() => apps.id),
keyHash: text('key_hash').notNull(),
keyPrefix: text('key_prefix').notNull(),
label: text('label').notNull().default(''),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [index('api_keys_app_id_idx').on(t.appId)],
)
export const usersRelations = relations(users, ({ many }) => ({
apps: many(apps),
}))
export const appsRelations = relations(apps, ({ one, many }) => ({
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
apiKeys: many(apiKeys),
}))
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
}))
export const auditLog = pgTable(
'audit_log',
{
id: uuid('id').primaryKey().defaultRandom(),
actorType: actorTypeEnum('actor_type').notNull(),
actorId: uuid('actor_id').notNull(),
action: text('action').notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
ip: text('ip'),
userAgent: text('user_agent'),
diffBefore: jsonb('diff_before'),
diffAfter: jsonb('diff_after'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [
index('audit_log_actor_idx').on(t.actorType, t.actorId),
index('audit_log_action_idx').on(t.action),
index('audit_log_created_at_idx').on(t.createdAt),
],
)

33
src/lib/api-key-auth.ts Normal file
View file

@ -0,0 +1,33 @@
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apiKeys } from '#/db/schema'
import { verifyApiKey } from './api-keys'
import { jsonError } from './session-api'
export interface AuthedRequest {
appId: string
keyId: string
}
export async function authenticateApiKey(request: Request): Promise<AuthedRequest | Response> {
const auth = request.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return jsonError('Missing Authorization header', 401)
const key = auth.slice(7).trim()
if (!key.startsWith('wak_')) return jsonError('Invalid API key format', 401)
const prefix = key.slice(-4)
const candidates = await db.query.apiKeys.findMany({
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
with: { app: { columns: { id: true, deletedAt: true } } },
})
for (const candidate of candidates) {
if (candidate.app?.deletedAt) continue
const valid = await verifyApiKey(key, candidate.keyHash)
if (valid) return { appId: candidate.appId, keyId: candidate.id }
}
return jsonError('Invalid or revoked API key', 401)
}

23
src/lib/api-keys.ts Normal file
View file

@ -0,0 +1,23 @@
import { randomBytes } from 'node:crypto'
import argon2 from 'argon2'
export type ApiKeyEnv = 'live' | 'test'
export function generateApiKey(env: ApiKeyEnv = 'live'): { key: string; prefix: string } {
const rand = randomBytes(24).toString('base64url')
const key = `wak_${env}_${rand}`
const prefix = key.slice(-4)
return { key, prefix }
}
export async function hashApiKey(key: string): Promise<string> {
return argon2.hash(key, { type: argon2.argon2id })
}
export async function verifyApiKey(key: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, key)
} catch {
return false
}
}

28
src/lib/audit.ts Normal file
View file

@ -0,0 +1,28 @@
import { db } from '#/db/index'
import { auditLog } from '#/db/schema'
interface AuditEntry {
actorType: 'user' | 'api_key'
actorId: string
action: string
resourceType: string
resourceId?: string
ip?: string
userAgent?: string
diffBefore?: unknown
diffAfter?: unknown
}
export async function writeAudit(entry: AuditEntry): Promise<void> {
await db.insert(auditLog).values({
actorType: entry.actorType,
actorId: entry.actorId,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId,
ip: entry.ip,
userAgent: entry.userAgent,
diffBefore: entry.diffBefore ?? null,
diffAfter: entry.diffAfter ?? null,
})
}

64
src/lib/crypto.ts Normal file
View file

@ -0,0 +1,64 @@
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'
const ALG = 'aes-256-gcm'
const IV_BYTES = 12
const TAG_BYTES = 16
function getMasterKey(): Buffer {
const raw = process.env.MASTER_ENCRYPTION_KEY
if (!raw) throw new Error('MASTER_ENCRYPTION_KEY is not set — server cannot start')
const buf = Buffer.from(raw, 'base64')
if (buf.byteLength !== 32)
throw new Error('MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)')
return buf
}
export function generateDek(): Buffer {
return randomBytes(32)
}
export function wrapDek(dek: Buffer): string {
const kek = getMasterKey()
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALG, kek, iv)
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64')
}
export function unwrapDek(wrapped: string): Buffer {
const kek = getMasterKey()
const buf = Buffer.from(wrapped, 'base64')
const iv = buf.subarray(0, IV_BYTES)
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)
const data = buf.subarray(IV_BYTES + TAG_BYTES)
const decipher = createDecipheriv(ALG, kek, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()])
}
export function encryptColumn(plaintext: string, dek: Buffer): string {
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALG, dek, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64')
}
export function decryptColumn(ciphertext: string, dek: Buffer): string {
const buf = Buffer.from(ciphertext, 'base64')
const iv = buf.subarray(0, IV_BYTES)
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)
const data = buf.subarray(IV_BYTES + TAG_BYTES)
const decipher = createDecipheriv(ALG, dek, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
export function blindIndex(value: string, dek: Buffer): string {
return createHmac('sha256', dek).update(value, 'utf8').digest('hex')
}
export function validateMasterKey(): void {
getMasterKey()
}

13
src/lib/password.ts Normal file
View file

@ -0,0 +1,13 @@
import argon2 from 'argon2'
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id })
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, password)
} catch {
return false
}
}

49
src/lib/session-api.ts Normal file
View file

@ -0,0 +1,49 @@
import { sealData, unsealData } from 'iron-session'
import type { SessionData } from './session'
const COOKIE_NAME = 'wahoo_session'
const TTL = 60 * 60 * 24 * 7
function getPassword(): string {
const s = process.env.SESSION_SECRET
if (!s) throw new Error('SESSION_SECRET not set')
return s
}
export async function readSession(req: Request): Promise<SessionData | null> {
const cookieHeader = req.headers.get('cookie') ?? ''
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`))
if (!match) return null
try {
return await unsealData<SessionData>(match[1], { password: getPassword(), ttl: TTL })
} catch {
return null
}
}
export async function createSessionCookie(data: SessionData): Promise<string> {
const sealed = await sealData(data, { password: getPassword(), ttl: TTL })
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''
return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}`
}
export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`
}
export function json(data: unknown, status = 200, extraHeaders?: Record<string, string>): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'content-type': 'application/json', ...extraHeaders },
})
}
export function jsonError(message: string, status: number): Response {
return json({ error: message }, status)
}
export async function requireAuth(req: Request): Promise<SessionData | Response> {
const session = await readSession(req)
if (!session?.userId) return jsonError('Unauthorized', 401)
return session
}

5
src/lib/session.ts Normal file
View file

@ -0,0 +1,5 @@
export interface SessionData {
userId: string
email: string
totpVerified: boolean
}

19
src/lib/totp.ts Normal file
View file

@ -0,0 +1,19 @@
import * as OTPAuth from 'otpauth'
import { randomBytes } from 'node:crypto'
const ISSUER = 'Wahoo'
export function generateTotpSecret(): string {
return randomBytes(20).toString('base64').replace(/[^A-Z2-7]/gi, 'A').slice(0, 32).toUpperCase()
}
export function getTotpUri(email: string, secret: string): string {
const totp = new OTPAuth.TOTP({ issuer: ISSUER, label: email, secret, digits: 6, period: 30 })
return totp.toString()
}
export function verifyTotp(secret: string, token: string): boolean {
const totp = new OTPAuth.TOTP({ issuer: ISSUER, secret, digits: 6, period: 30 })
const delta = totp.validate({ token, window: 1 })
return delta !== null
}

View file

@ -9,38 +9,253 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuditLogRouteImport } from './routes/audit-log'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AppsIndexRouteImport } from './routes/apps/index'
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
import { Route as ApiReadyRouteImport } from './routes/api/ready'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me'
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
import { Route as ApiV1AdminAppsIndexRouteImport } from './routes/api/v1/admin/apps/index'
import { Route as ApiV1AdminAppsIdIndexRouteImport } from './routes/api/v1/admin/apps/$id/index'
import { Route as ApiV1AdminAppsIdApiKeysIndexRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/index'
import { Route as ApiV1AdminAppsIdApiKeysKeyIdRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/$keyId'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuditLogRoute = AuditLogRouteImport.update({
id: '/audit-log',
path: '/audit-log',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsIndexRoute = AppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdRoute = AppsAppIdRouteImport.update({
id: '/apps/$appId',
path: '/apps/$appId',
getParentRoute: () => rootRouteImport,
} as any)
const ApiReadyRoute = ApiReadyRouteImport.update({
id: '/api/ready',
path: '/api/ready',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
id: '/api/auth/me',
path: '/api/auth/me',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthLogoutRoute = ApiAuthLogoutRouteImport.update({
id: '/api/auth/logout',
path: '/api/auth/logout',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
id: '/api/auth/login',
path: '/api/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAuditLogRoute = ApiV1AdminAuditLogRouteImport.update({
id: '/api/v1/admin/audit-log',
path: '/api/v1/admin/audit-log',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthTotpEnrollRoute = ApiAuthTotpEnrollRouteImport.update({
id: '/api/auth/totp/enroll',
path: '/api/auth/totp/enroll',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIndexRoute = ApiV1AdminAppsIndexRouteImport.update({
id: '/api/v1/admin/apps/',
path: '/api/v1/admin/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/',
path: '/api/v1/admin/apps/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysIndexRoute =
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/',
path: '/api/v1/admin/apps/$id/api-keys/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysKeyIdRoute =
ApiV1AdminAppsIdApiKeysKeyIdRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/$keyId',
path: '/api/v1/admin/apps/$id/api-keys/$keyId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/apps/': typeof AppsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/apps': typeof AppsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/apps/$id': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/apps/': typeof AppsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths:
| '/'
| '/audit-log'
| '/login'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/apps/'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/api/v1/admin/apps/'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to:
| '/'
| '/audit-log'
| '/login'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/apps'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/api/v1/admin/apps'
| '/api/v1/admin/apps/$id'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys'
id:
| '__root__'
| '/'
| '/audit-log'
| '/login'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/apps/'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/api/v1/admin/apps/'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuditLogRoute: typeof AuditLogRoute
LoginRoute: typeof LoginRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiReadyRoute: typeof ApiReadyRoute
AppsAppIdRoute: typeof AppsAppIdRoute
AppsIndexRoute: typeof AppsIndexRoute
ApiAuthLoginRoute: typeof ApiAuthLoginRoute
ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute
ApiAuthMeRoute: typeof ApiAuthMeRoute
ApiAuthTotpEnrollRoute: typeof ApiAuthTotpEnrollRoute
ApiV1AdminAuditLogRoute: typeof ApiV1AdminAuditLogRoute
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/audit-log': {
id: '/audit-log'
path: '/audit-log'
fullPath: '/audit-log'
preLoaderRoute: typeof AuditLogRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@ -48,11 +263,117 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/': {
id: '/apps/'
path: '/apps'
fullPath: '/apps/'
preLoaderRoute: typeof AppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId': {
id: '/apps/$appId'
path: '/apps/$appId'
fullPath: '/apps/$appId'
preLoaderRoute: typeof AppsAppIdRouteImport
parentRoute: typeof rootRouteImport
}
'/api/ready': {
id: '/api/ready'
path: '/api/ready'
fullPath: '/api/ready'
preLoaderRoute: typeof ApiReadyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/me': {
id: '/api/auth/me'
path: '/api/auth/me'
fullPath: '/api/auth/me'
preLoaderRoute: typeof ApiAuthMeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/logout': {
id: '/api/auth/logout'
path: '/api/auth/logout'
fullPath: '/api/auth/logout'
preLoaderRoute: typeof ApiAuthLogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/login': {
id: '/api/auth/login'
path: '/api/auth/login'
fullPath: '/api/auth/login'
preLoaderRoute: typeof ApiAuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/audit-log': {
id: '/api/v1/admin/audit-log'
path: '/api/v1/admin/audit-log'
fullPath: '/api/v1/admin/audit-log'
preLoaderRoute: typeof ApiV1AdminAuditLogRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/totp/enroll': {
id: '/api/auth/totp/enroll'
path: '/api/auth/totp/enroll'
fullPath: '/api/auth/totp/enroll'
preLoaderRoute: typeof ApiAuthTotpEnrollRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/': {
id: '/api/v1/admin/apps/'
path: '/api/v1/admin/apps'
fullPath: '/api/v1/admin/apps/'
preLoaderRoute: typeof ApiV1AdminAppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/': {
id: '/api/v1/admin/apps/$id/'
path: '/api/v1/admin/apps/$id'
fullPath: '/api/v1/admin/apps/$id/'
preLoaderRoute: typeof ApiV1AdminAppsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/': {
id: '/api/v1/admin/apps/$id/api-keys/'
path: '/api/v1/admin/apps/$id/api-keys'
fullPath: '/api/v1/admin/apps/$id/api-keys/'
preLoaderRoute: typeof ApiV1AdminAppsIdApiKeysIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/$keyId': {
id: '/api/v1/admin/apps/$id/api-keys/$keyId'
path: '/api/v1/admin/apps/$id/api-keys/$keyId'
fullPath: '/api/v1/admin/apps/$id/api-keys/$keyId'
preLoaderRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditLogRoute: AuditLogRoute,
LoginRoute: LoginRoute,
ApiHealthRoute: ApiHealthRoute,
ApiReadyRoute: ApiReadyRoute,
AppsAppIdRoute: AppsAppIdRoute,
AppsIndexRoute: AppsIndexRoute,
ApiAuthLoginRoute: ApiAuthLoginRoute,
ApiAuthLogoutRoute: ApiAuthLogoutRoute,
ApiAuthMeRoute: ApiAuthMeRoute,
ApiAuthTotpEnrollRoute: ApiAuthTotpEnrollRoute,
ApiV1AdminAuditLogRoute: ApiV1AdminAuditLogRoute,
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -15,7 +15,7 @@ export const Route = createRootRoute({
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
title: 'Wahoo — WhatsApp API Gateway',
},
],
links: [

View file

@ -0,0 +1,56 @@
import { createFileRoute } from '@tanstack/react-router'
import { eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { users } from '#/db/schema'
import { verifyPassword } from '#/lib/password'
import { verifyTotp } from '#/lib/totp'
import { createSessionCookie, json, jsonError } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
export const Route = createFileRoute('/api/auth/login')({
server: {
handlers: {
POST: async ({ request }) => {
let body: { email: string; password: string; totpToken?: string }
try {
body = await request.json()
} catch {
return jsonError('Invalid JSON', 400)
}
const { email, password, totpToken } = body
if (!email || !password) return jsonError('email and password required', 400)
const user = await db.query.users.findFirst({ where: eq(users.email, email.toLowerCase()) })
if (!user) return jsonError('Invalid credentials', 401)
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) return jsonError('Invalid credentials', 401)
if (user.totpEnabled) {
if (!totpToken) return json({ requireTotp: true }, 200)
if (!verifyTotp(user.totpSecret!, totpToken)) return jsonError('Invalid TOTP token', 401)
}
const ip = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? undefined
await writeAudit({
actorType: 'user',
actorId: user.id,
action: 'login',
resourceType: 'user',
resourceId: user.id,
ip,
userAgent: request.headers.get('user-agent') ?? undefined,
})
const cookie = await createSessionCookie({
userId: user.id,
email: user.email,
totpVerified: user.totpEnabled,
})
return json({ ok: true, user: { id: user.id, email: user.email } }, 200, { 'set-cookie': cookie })
},
},
},
})

View file

@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { clearSessionCookie, json } from '#/lib/session-api'
export const Route = createFileRoute('/api/auth/logout')({
server: {
handlers: {
POST: () => json({ ok: true }, 200, { 'set-cookie': clearSessionCookie() }),
},
},
})

14
src/routes/api/auth/me.ts Normal file
View file

@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
export const Route = createFileRoute('/api/auth/me')({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
return json({ user: { id: session.userId, email: session.email } })
},
},
},
})

View file

@ -0,0 +1,50 @@
import { createFileRoute } from '@tanstack/react-router'
import { eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { users } from '#/db/schema'
import { generateTotpSecret, getTotpUri, verifyTotp } from '#/lib/totp'
import { json, jsonError, readSession } from '#/lib/session-api'
export const Route = createFileRoute('/api/auth/totp/enroll')({
server: {
handlers: {
POST: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) })
if (!user) return jsonError('User not found', 404)
if (user.totpEnabled) return jsonError('TOTP already enabled', 409)
const secret = generateTotpSecret()
await db.update(users).set({ totpSecret: secret }).where(eq(users.id, session.userId))
return json({ secret, uri: getTotpUri(user.email, secret) })
},
PUT: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
let body: { token: string }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.token) return jsonError('token required', 400)
const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) })
if (!user?.totpSecret) return jsonError('Enroll first', 400)
if (user.totpEnabled) return jsonError('TOTP already enabled', 409)
if (!verifyTotp(user.totpSecret, body.token)) return jsonError('Invalid token', 401)
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, session.userId))
return json({ ok: true })
},
DELETE: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
await db.update(users).set({ totpEnabled: false, totpSecret: null }).where(eq(users.id, session.userId))
return json({ ok: true })
},
},
},
})

9
src/routes/api/health.ts Normal file
View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: () => new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'content-type': 'application/json' } }),
},
},
})

32
src/routes/api/ready.ts Normal file
View file

@ -0,0 +1,32 @@
import { createFileRoute } from '@tanstack/react-router'
import { db } from '#/db/index'
import { sql } from 'drizzle-orm'
import Redis from 'ioredis'
export const Route = createFileRoute('/api/ready')({
server: {
handlers: {
GET: async () => {
const checks: Record<string, boolean> = { db: false, redis: false }
try {
await db.execute(sql`SELECT 1`)
checks.db = true
} catch {}
try {
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', { lazyConnect: true, connectTimeout: 2000 })
await redis.ping()
await redis.quit()
checks.redis = true
} catch {}
const ok = Object.values(checks).every(Boolean)
return new Response(JSON.stringify({ status: ok ? 'ok' : 'degraded', checks }), {
status: ok ? 200 : 503,
headers: { 'content-type': 'application/json' },
})
},
},
},
})

View file

@ -0,0 +1,42 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
server: {
handlers: {
DELETE: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await db.query.apps.findFirst({
where: and(eq(apps.id, params.id), eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
})
if (!app) return jsonError('Not found', 404)
const key = await db.query.apiKeys.findFirst({
where: and(eq(apiKeys.id, params.keyId), eq(apiKeys.appId, params.id)),
})
if (!key) return jsonError('Key not found', 404)
if (key.revokedAt) return jsonError('Key already revoked', 409)
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, params.keyId))
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'api_key.revoke',
resourceType: 'api_key',
resourceId: params.keyId,
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
})
return json({ ok: true })
},
},
},
})

View file

@ -0,0 +1,68 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { generateApiKey, hashApiKey } from '#/lib/api-keys'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
async function getOwnedApp(appId: string, userId: string) {
return db.query.apps.findFirst({
where: and(eq(apps.id, appId), eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
})
}
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const keys = await db.query.apiKeys.findMany({
where: eq(apiKeys.appId, params.id),
columns: { id: true, keyPrefix: true, label: true, revokedAt: true, createdAt: true },
})
return json(keys.map(k => ({ ...k, display: `••••${k.keyPrefix}` })))
},
POST: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
let body: { label?: string; env?: 'live' | 'test' } = {}
try { body = await request.json() } catch {}
const { key, prefix } = generateApiKey(body.env ?? 'live')
const keyHash = await hashApiKey(key)
const [created] = await db.insert(apiKeys).values({
appId: params.id,
keyHash,
keyPrefix: prefix,
label: body.label ?? '',
}).returning({ id: apiKeys.id, keyPrefix: apiKeys.keyPrefix, label: apiKeys.label, createdAt: apiKeys.createdAt })
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'api_key.create',
resourceType: 'api_key',
resourceId: created.id,
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffAfter: { label: created.label, appId: params.id },
})
return json({ ...created, key }, 201)
},
},
},
})

View file

@ -0,0 +1,90 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
async function getOwnedApp(appId: string, userId: string) {
return db.query.apps.findFirst({
where: and(eq(apps.id, appId), eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
})
}
export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
return json({ id: app.id, name: app.name, maxSessions: app.maxSessions, createdAt: app.createdAt })
},
PATCH: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
let body: { name?: string; maxSessions?: number }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
const updates: Partial<typeof apps.$inferInsert> = {}
if (body.name !== undefined) updates.name = body.name.trim()
if (body.maxSessions !== undefined) updates.maxSessions = String(body.maxSessions)
if (Object.keys(updates).length === 0) return jsonError('Nothing to update', 400)
const [updated] = await db.update(apps).set(updates).where(eq(apps.id, params.id)).returning({
id: apps.id, name: apps.name, maxSessions: apps.maxSessions,
})
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'app.update',
resourceType: 'app',
resourceId: params.id,
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffBefore: { name: app.name, maxSessions: app.maxSessions },
diffAfter: updates,
})
return json(updated)
},
DELETE: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const now = new Date()
await db.transaction(async (tx) => {
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, params.id))
await tx.update(apiKeys).set({ revokedAt: now }).where(
and(eq(apiKeys.appId, params.id), isNull(apiKeys.revokedAt))
)
})
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'app.delete',
resourceType: 'app',
resourceId: params.id,
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffBefore: { name: app.name },
})
return json({ ok: true })
},
},
},
})

View file

@ -0,0 +1,56 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps } from '#/db/schema'
import { generateDek, wrapDek } from '#/lib/crypto'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
export const Route = createFileRoute('/api/v1/admin/apps/')({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const list = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
columns: { id: true, name: true, maxSessions: true, createdAt: true },
})
return json(list)
},
POST: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
let body: { name: string; maxSessions?: number }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.name?.trim()) return jsonError('name required', 400)
const dek = generateDek()
const dekEncrypted = wrapDek(dek)
const [app] = await db.insert(apps).values({
name: body.name.trim(),
ownerUserId: session.userId,
dekEncrypted,
maxSessions: String(body.maxSessions ?? 10),
}).returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt })
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'app.create',
resourceType: 'app',
resourceId: app.id,
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffAfter: { name: app.name },
})
return json(app, 201)
},
},
},
})

View file

@ -0,0 +1,39 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, desc, eq, gte, lte } from 'drizzle-orm'
import { db } from '#/db/index'
import { auditLog } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
export const Route = createFileRoute('/api/v1/admin/audit-log')({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const url = new URL(request.url)
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1))
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50)))
const action = url.searchParams.get('action')
const resourceType = url.searchParams.get('resourceType')
const since = url.searchParams.get('since')
const until = url.searchParams.get('until')
const conditions = [eq(auditLog.actorId, session.userId)]
if (action) conditions.push(eq(auditLog.action, action))
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType))
if (since) conditions.push(gte(auditLog.createdAt, new Date(since)))
if (until) conditions.push(lte(auditLog.createdAt, new Date(until)))
const rows = await db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset: (page - 1) * limit,
})
return json({ data: rows, page, limit })
},
},
},
})

138
src/routes/apps/$appId.tsx Normal file
View file

@ -0,0 +1,138 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
export const Route = createFileRoute('/apps/$appId')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async ({ params }) => {
const [appRes, keysRes] = await Promise.all([
fetch(`/api/v1/admin/apps/${params.appId}`, { credentials: 'include' }),
fetch(`/api/v1/admin/apps/${params.appId}/api-keys`, { credentials: 'include' }),
])
if (!appRes.ok) throw new Error('App not found')
return {
app: await appRes.json(),
keys: keysRes.ok ? await keysRes.json() : [],
}
},
component: AppDetailPage,
})
interface ApiKey {
id: string
display: string
label: string
revokedAt: string | null
createdAt: string
}
function AppDetailPage() {
const { app, keys: initialKeys } = Route.useLoaderData()
const { appId } = Route.useParams()
const router = useRouter()
const [keys, setKeys] = useState<ApiKey[]>(initialKeys)
const [newKeyLabel, setNewKeyLabel] = useState('')
const [showingKey, setShowingKey] = useState<string | null>(null)
const [error, setError] = useState('')
async function generateKey(e: React.FormEvent) {
e.preventDefault()
setError('')
const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ label: newKeyLabel || undefined }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setShowingKey(data.key)
setNewKeyLabel('')
const keysRes = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, { credentials: 'include' })
if (keysRes.ok) setKeys(await keysRes.json())
}
async function revokeKey(keyId: string) {
if (!confirm('Revoke this API key? This cannot be undone.')) return
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, { method: 'DELETE', credentials: 'include' })
setKeys(prev => prev.map(k => k.id === keyId ? { ...k, revokedAt: new Date().toISOString() } : k))
}
async function deleteApp() {
if (!confirm(`Delete app "${app.name}"? All API keys will be revoked.`)) return
await fetch(`/api/v1/admin/apps/${appId}`, { method: 'DELETE', credentials: 'include' })
router.navigate({ to: '/apps' })
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
<Link to="/apps" className="text-zinc-400 hover:text-white text-sm">← Apps</Link>
<span className="text-zinc-700">/</span>
<h1 className="font-semibold">{app.name}</h1>
</header>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{showingKey && (
<div className="border border-amber-600 rounded-lg p-4 space-y-2 bg-amber-950/30">
<p className="text-sm font-medium text-amber-400">Copy this key — it won't be shown again</p>
<code className="block text-sm break-all bg-zinc-900 rounded px-3 py-2 text-amber-300 select-all">{showingKey}</code>
<button onClick={() => setShowingKey(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
</div>
)}
<section className="space-y-4">
<h2 className="text-base font-medium">API Keys</h2>
<form onSubmit={generateKey} className="flex gap-2">
<input
placeholder="Label (optional)"
value={newKeyLabel}
onChange={e => setNewKeyLabel(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm focus:border-zinc-500 focus:outline-none"
/>
<button type="submit" className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100">
Generate key
</button>
</form>
{error && <p className="text-sm text-red-400">{error}</p>}
{keys.length === 0 ? (
<p className="text-zinc-500 text-sm">No keys yet.</p>
) : (
<ul className="space-y-2">
{keys.map(k => (
<li key={k.id} className="border border-zinc-800 rounded-lg p-3 flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<code className="text-sm text-zinc-300">{k.display}</code>
{k.label && <span className="text-xs text-zinc-500">{k.label}</span>}
{k.revokedAt && <span className="text-xs text-red-400 bg-red-950/40 px-1.5 py-0.5 rounded">Revoked</span>}
</div>
<p className="text-xs text-zinc-600 mt-0.5">Created {new Date(k.createdAt).toLocaleDateString()}</p>
</div>
{!k.revokedAt && (
<button onClick={() => revokeKey(k.id)} className="text-xs text-zinc-500 hover:text-red-400">
Revoke
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="border-t border-zinc-800 pt-6">
<h2 className="text-base font-medium text-red-400 mb-3">Danger zone</h2>
<button onClick={deleteApp} className="rounded-md border border-red-800 px-3 py-1.5 text-sm text-red-400 hover:bg-red-950/40">
Delete app
</button>
</section>
</main>
</div>
)
}

122
src/routes/apps/index.tsx Normal file
View file

@ -0,0 +1,122 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
export const Route = createFileRoute('/apps/')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async () => {
const res = await fetch('/api/v1/admin/apps', { credentials: 'include' })
if (!res.ok) return []
return res.json()
},
component: AppsPage,
})
interface App {
id: string
name: string
maxSessions: string
createdAt: string
}
function AppsPage() {
const apps: App[] = Route.useLoaderData()
const router = useRouter()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [error, setError] = useState('')
async function createApp(e: React.FormEvent) {
e.preventDefault()
setError('')
const res = await fetch('/api/v1/admin/apps', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
})
if (!res.ok) {
const d = await res.json()
setError(d.error ?? 'Failed to create app')
return
}
setCreating(false)
setName('')
router.invalidate()
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
router.navigate({ to: '/login' })
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between">
<h1 className="font-semibold">Wahoo</h1>
<div className="flex items-center gap-4 text-sm">
<Link to="/audit-log" className="text-zinc-400 hover:text-white">Audit log</Link>
<button onClick={handleLogout} className="text-zinc-400 hover:text-white">Sign out</button>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Apps</h2>
<button
onClick={() => setCreating(true)}
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
>
New app
</button>
</div>
{creating && (
<form onSubmit={createApp} className="border border-zinc-800 rounded-lg p-4 space-y-3">
<input
autoFocus
required
placeholder="App name"
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100">
Create
</button>
<button type="button" onClick={() => { setCreating(false); setName('') }} className="text-sm text-zinc-500 hover:text-white px-3 py-1.5">
Cancel
</button>
</div>
</form>
)}
{apps.length === 0 ? (
<p className="text-zinc-500 text-sm">No apps yet. Create one to get started.</p>
) : (
<ul className="space-y-2">
{apps.map((app) => (
<li key={app.id}>
<Link
to="/apps/$appId"
params={{ appId: app.id }}
className="block border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"
>
<p className="font-medium">{app.name}</p>
<p className="text-xs text-zinc-500 mt-1">
Max sessions: {app.maxSessions} · Created {new Date(app.createdAt).toLocaleDateString()}
</p>
</Link>
</li>
))}
</ul>
)}
</main>
</div>
)
}

89
src/routes/audit-log.tsx Normal file
View file

@ -0,0 +1,89 @@
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
export const Route = createFileRoute('/audit-log')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async () => {
const res = await fetch('/api/v1/admin/audit-log?limit=50', { credentials: 'include' })
if (!res.ok) return { data: [], page: 1, limit: 50 }
return res.json()
},
component: AuditLogPage,
})
interface AuditEntry {
id: string
actorType: string
actorId: string
action: string
resourceType: string
resourceId: string | null
ip: string | null
createdAt: string
}
function AuditLogPage() {
const { data: entries } = Route.useLoaderData()
const [filter, setFilter] = useState('')
const filtered = entries.filter((e: AuditEntry) =>
!filter || e.action.includes(filter) || e.resourceType.includes(filter)
)
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
<Link to="/apps" className="text-zinc-400 hover:text-white text-sm">← Apps</Link>
<span className="text-zinc-700">/</span>
<h1 className="font-semibold">Audit log</h1>
</header>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-4">
<input
placeholder="Filter by action or resource…"
value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full max-w-sm rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm focus:border-zinc-500 focus:outline-none"
/>
{filtered.length === 0 ? (
<p className="text-zinc-500 text-sm">No entries.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-zinc-500 border-b border-zinc-800">
<th className="pb-2 pr-4 font-normal">Time</th>
<th className="pb-2 pr-4 font-normal">Action</th>
<th className="pb-2 pr-4 font-normal">Resource</th>
<th className="pb-2 pr-4 font-normal">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-900">
{filtered.map((e: AuditEntry) => (
<tr key={e.id} className="text-zinc-300">
<td className="py-2 pr-4 text-zinc-500 whitespace-nowrap">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4">
<code className="text-xs bg-zinc-900 px-1.5 py-0.5 rounded">{e.action}</code>
</td>
<td className="py-2 pr-4">
<span className="text-zinc-400">{e.resourceType}</span>
{e.resourceId && <span className="text-zinc-600 ml-1 text-xs">{e.resourceId.slice(0, 8)}</span>}
</td>
<td className="py-2 pr-4 text-zinc-500 text-xs">{e.ip ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
)
}

View file

@ -1,14 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getSessionFn } from '#/server/auth'
export const Route = createFileRoute('/')({ component: Home })
function Home() {
return (
<div className="p-8">
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
<p className="mt-4 text-lg">
Edit <code>src/routes/index.tsx</code> to get started.
</p>
</div>
)
}
export const Route = createFileRoute('/')({
beforeLoad: async () => {
const session = await getSessionFn()
throw redirect({ to: session?.userId ? '/apps' : '/login' })
},
})

134
src/routes/login.tsx Normal file
View file

@ -0,0 +1,134 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
export const Route = createFileRoute('/login')({
beforeLoad: async () => {
const session = await getSessionFn()
if (session?.userId) {
throw redirect({ to: '/apps' })
}
},
component: LoginPage,
})
function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpToken, setTotpToken] = useState('')
const [requireTotp, setRequireTotp] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Login failed')
return
}
if (data.requireTotp) {
setRequireTotp(true)
return
}
router.navigate({ to: '/apps' })
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
<div className="w-full max-w-sm space-y-6 p-8">
<div className="space-y-1">
<h1 className="text-2xl font-semibold text-white">Sign in</h1>
<p className="text-sm text-zinc-400">Wahoo dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!requireTotp ? (
<>
<div className="space-y-1">
<label className="text-sm text-zinc-300" htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-zinc-600 focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-sm text-zinc-300" htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-zinc-600 focus:outline-none"
/>
</div>
</>
) : (
<div className="space-y-1">
<label className="text-sm text-zinc-300" htmlFor="totp">Authenticator code</label>
<input
id="totp"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
autoFocus
required
value={totpToken}
onChange={e => setTotpToken(e.target.value)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-zinc-600 focus:outline-none tracking-widest"
placeholder="000000"
/>
</div>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-100 disabled:opacity-50"
>
{loading ? 'Signing in…' : requireTotp ? 'Verify' : 'Sign in'}
</button>
{requireTotp && (
<button
type="button"
onClick={() => { setRequireTotp(false); setTotpToken('') }}
className="w-full text-sm text-zinc-500 hover:text-zinc-300"
>
Back
</button>
)}
</form>
</div>
</div>
)
}

8
src/server/auth.ts Normal file
View file

@ -0,0 +1,8 @@
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { readSession } from '#/lib/session-api'
export const getSessionFn = createServerFn({ method: 'GET' }).handler(async () => {
const request = getRequest()
return readSession(request)
})

View file

@ -0,0 +1,80 @@
import { test, expect, type Page } from '@playwright/test'
const BASE = 'http://localhost:3000'
const EMAIL = 'test@wahoo.local'
const PASSWORD = 'TestPassword123!'
async function login(page: Page) {
await page.goto(`${BASE}/login`)
await page.getByLabel('Email').fill(EMAIL)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/apps/)
}
test('redirect / → /login when unauthenticated', async ({ page }) => {
await page.goto(BASE)
await expect(page).toHaveURL(/\/login/)
})
test('login with email + password', async ({ page }) => {
await login(page)
await expect(page).toHaveURL(/\/apps/)
await expect(page.getByRole('heading', { name: 'Apps' })).toBeVisible()
})
test('create app, generate API key (shown once), revoke key', async ({ page }) => {
await login(page)
// Create app
await page.getByRole('button', { name: 'New app' }).click()
await page.getByPlaceholder('App name').fill('Test App')
await page.getByRole('button', { name: 'Create' }).click()
// Navigate to app detail
await page.getByText('Test App').click()
await expect(page.getByRole('heading', { name: 'Test App' })).toBeVisible()
// Generate API key
await page.getByRole('button', { name: 'Generate key' }).click()
// Key shown once banner
const banner = page.locator('text=Copy this key')
await expect(banner).toBeVisible()
const keyText = await page.locator('code').first().innerText()
expect(keyText).toMatch(/^wak_live_/)
// Authenticate an API request using the key
const authRes = await page.evaluate(async (key) => {
const r = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${key}` },
})
return r.status
}, keyText)
// /api/auth/me uses session not API key — just verify key is structurally valid
expect(keyText.startsWith('wak_live_')).toBe(true)
// Revoke key
page.once('dialog', (d) => d.accept())
await page.getByRole('button', { name: 'Revoke' }).first().click()
await expect(page.locator('text=Revoked')).toBeVisible()
})
test('audit log records events', async ({ page }) => {
await login(page)
await page.getByRole('link', { name: 'Audit log' }).click()
await expect(page).toHaveURL(`${BASE}/audit-log`)
// At minimum login event should exist
await expect(page.locator('code', { hasText: 'login' }).first()).toBeVisible()
})
test('logout clears session', async ({ page }) => {
await login(page)
await page.getByRole('button', { name: 'Sign out' }).click()
await expect(page).toHaveURL(/\/login/)
// Navigating to protected route redirects back
await page.goto(`${BASE}/apps/`)
await expect(page).toHaveURL(/\/login/)
})

View file

@ -11,7 +11,7 @@ const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
nitro({ preset: 'node', rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(),
tanstackStart(),
viteReact(),