From b1c8a4ae6c461592b1fc531f5ec25d655ae1f79b Mon Sep 17 00:00:00 2001 From: Nico Date: Sat, 14 Feb 2026 00:39:20 -0800 Subject: [PATCH] harden public launch api contracts and ops baseline --- .gitea/workflows/deploy-dokploy.yml | 56 ++++++++++ apps/web/__tests__/buckets.test.ts | 2 +- apps/web/__tests__/errors-response.test.ts | 14 +++ apps/web/__tests__/rate-limit.test.ts | 101 +++++++++++++++++ apps/web/__tests__/recurring-entries.test.ts | 2 +- apps/web/__tests__/spendings.test.ts | 2 +- apps/web/app/api/auth/login/route.ts | 13 ++- apps/web/app/api/auth/logout/route.ts | 32 ++++-- apps/web/app/api/auth/me/route.ts | 16 ++- apps/web/app/api/auth/register/route.ts | 17 +-- apps/web/app/api/buckets/[id]/route.ts | 20 ++-- apps/web/app/api/buckets/route.ts | 15 +-- apps/web/app/api/entries/[id]/route.ts | 28 ++--- apps/web/app/api/entries/route.ts | 23 ++-- apps/web/app/api/groups/active/route.ts | 9 +- apps/web/app/api/groups/audit/route.ts | 5 +- apps/web/app/api/groups/delete/route.ts | 5 +- .../app/api/groups/invites/delete/route.ts | 7 +- .../app/api/groups/invites/revive/route.ts | 9 +- .../app/api/groups/invites/revoke/route.ts | 7 +- apps/web/app/api/groups/invites/route.ts | 9 +- apps/web/app/api/groups/join/route.ts | 7 +- .../app/api/groups/members/approve/route.ts | 7 +- .../app/api/groups/members/demote/route.ts | 7 +- apps/web/app/api/groups/members/deny/route.ts | 7 +- apps/web/app/api/groups/members/kick/route.ts | 7 +- .../web/app/api/groups/members/leave/route.ts | 5 +- .../app/api/groups/members/promote/route.ts | 7 +- apps/web/app/api/groups/members/route.ts | 5 +- .../groups/members/transfer-owner/route.ts | 7 +- apps/web/app/api/groups/rename/route.ts | 7 +- apps/web/app/api/groups/route.ts | 9 +- apps/web/app/api/groups/settings/route.ts | 7 +- apps/web/app/api/health/live/route.ts | 12 ++ apps/web/app/api/health/ready/route.ts | 25 +++++ .../web/app/api/invite-links/[token]/route.ts | 6 +- .../app/api/recurring-entries/[id]/route.ts | 28 ++--- apps/web/app/api/recurring-entries/route.ts | 23 ++-- apps/web/app/api/tags/[name]/route.ts | 2 +- apps/web/app/api/tags/route.ts | 7 +- apps/web/lib/server/auth-service.ts | 2 +- apps/web/lib/server/buckets.ts | 6 +- apps/web/lib/server/db.ts | 4 +- apps/web/lib/server/entries.ts | 9 +- apps/web/lib/server/errors.ts | 7 +- apps/web/lib/server/group-invites.ts | 17 ++- apps/web/lib/server/group-members.ts | 12 +- apps/web/lib/server/group-settings.ts | 2 + apps/web/lib/server/groups.ts | 18 ++- apps/web/lib/server/rate-limit.ts | 105 ++++++++++++++++++ apps/web/lib/server/recurring-entries.ts | 2 +- apps/web/lib/server/tags.ts | 3 + apps/web/next.config.mjs | 22 +++- docker/nginx/fiddy.conf | 59 ++++++++++ docker/nginx/includes/fiddy-proxy.conf | 10 ++ .../docker-compose.observability.yml | 53 +++++++++ docker/observability/loki-config.yml | 31 ++++++ docker/observability/promtail-config.yml | 26 +++++ docs/05_REFACTOR_2.md | 68 ++++++++++++ docs/public-launch-runbook.md | 65 +++++++++++ packages/db/migrations/007_rate_limits.sql | 8 ++ scripts/backup-postgres.sh | 24 ++++ scripts/restore-postgres.sh | 19 ++++ 63 files changed, 968 insertions(+), 181 deletions(-) create mode 100644 .gitea/workflows/deploy-dokploy.yml create mode 100644 apps/web/__tests__/errors-response.test.ts create mode 100644 apps/web/__tests__/rate-limit.test.ts create mode 100644 apps/web/app/api/health/live/route.ts create mode 100644 apps/web/app/api/health/ready/route.ts create mode 100644 apps/web/lib/server/rate-limit.ts create mode 100644 docker/nginx/fiddy.conf create mode 100644 docker/nginx/includes/fiddy-proxy.conf create mode 100644 docker/observability/docker-compose.observability.yml create mode 100644 docker/observability/loki-config.yml create mode 100644 docker/observability/promtail-config.yml create mode 100644 docs/05_REFACTOR_2.md create mode 100644 docs/public-launch-runbook.md create mode 100644 packages/db/migrations/007_rate_limits.sql create mode 100644 scripts/backup-postgres.sh create mode 100644 scripts/restore-postgres.sh diff --git a/.gitea/workflows/deploy-dokploy.yml b/.gitea/workflows/deploy-dokploy.yml new file mode 100644 index 0000000..ec1788b --- /dev/null +++ b/.gitea/workflows/deploy-dokploy.yml @@ -0,0 +1,56 @@ +name: Build & Deploy Fiddy (Dokploy) + +on: + push: + branches: [ "main" ] + +env: + REGISTRY: git.nicosaya.com/nalalangan/fiddy + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test --if-present + + - name: Docker login + run: | + echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build Web Image + run: | + docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:main -f docker/Dockerfile . + + - name: Push Web Image + run: | + docker push $REGISTRY/web:${{ github.sha }} + docker push $REGISTRY/web:main + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Trigger Dokploy Deploy + env: + DOKPLOY_DEPLOY_HOOK: ${{ secrets.DOKPLOY_DEPLOY_HOOK }} + IMAGE_TAG: ${{ github.sha }} + run: | + if [ -z "$DOKPLOY_DEPLOY_HOOK" ]; then + echo "Missing DOKPLOY_DEPLOY_HOOK secret" + exit 1 + fi + curl -fsS -X POST "$DOKPLOY_DEPLOY_HOOK" \ + -H "Content-Type: application/json" \ + -d "{\"imageTag\":\"$IMAGE_TAG\"}" diff --git a/apps/web/__tests__/buckets.test.ts b/apps/web/__tests__/buckets.test.ts index 843ee74..14f84d4 100644 --- a/apps/web/__tests__/buckets.test.ts +++ b/apps/web/__tests__/buckets.test.ts @@ -75,7 +75,7 @@ test("buckets CRUD", async t => { assert.equal(updated?.name, "Groceries+"); assert.deepEqual(updated?.tags.sort(), ["groceries"]); - await deleteBucket({ id: bucket.id, groupId }); + await deleteBucket({ id: bucket.id, groupId, userId }); const listAfter = await listBuckets(groupId); assert.equal(listAfter.length, 0); } finally { diff --git a/apps/web/__tests__/errors-response.test.ts b/apps/web/__tests__/errors-response.test.ts new file mode 100644 index 0000000..ff53324 --- /dev/null +++ b/apps/web/__tests__/errors-response.test.ts @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ApiError, toErrorResponse } from "../lib/server/errors"; + +test("toErrorResponse includes request_id alias", async () => { + const prevDebug = process.env.DEBUG_API; + process.env.DEBUG_API = "0"; + const { status, body } = toErrorResponse(new ApiError("UNAUTHORIZED"), "GET /api/example", "req_test_1"); + assert.equal(status, 401); + assert.equal(body.requestId, "req_test_1"); + assert.equal(body.request_id, "req_test_1"); + assert.equal(body.error.code, "UNAUTHORIZED"); + process.env.DEBUG_API = prevDebug; +}); diff --git a/apps/web/__tests__/rate-limit.test.ts b/apps/web/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..b243d56 --- /dev/null +++ b/apps/web/__tests__/rate-limit.test.ts @@ -0,0 +1,101 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import getPool from "../lib/server/db"; +import { ApiError } from "../lib/server/errors"; +import { enforceAuthRateLimit, enforceUserWriteRateLimit } from "../lib/server/rate-limit"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); +const hasDb = Boolean(process.env.DATABASE_URL); + +async function ensureRateLimitTable() { + const pool = getPool(); + await pool.query(` + create table if not exists rate_limits( + key text primary key, + window_start timestamptz not null, + count integer not null default 0, + updated_at timestamptz not null default now() + ) + `); +} + +test("auth rate limit blocks when threshold is exceeded", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + await ensureRateLimitTable(); + const pool = getPool(); + const marker = Date.now(); + const ip = `test-ip-${marker}`; + const identifier = `rate_limit_${marker}@example.com`; + try { + await enforceAuthRateLimit({ + route: "login", + ip, + identifier, + ipLimit: 1, + identifierLimit: 1, + windowMs: 60_000 + }); + + await assert.rejects( + () => enforceAuthRateLimit({ + route: "login", + ip, + identifier, + ipLimit: 1, + identifierLimit: 1, + windowMs: 60_000 + }), + (error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED" + ); + } finally { + await pool.query("delete from rate_limits where key like $1 or key like $2", [ + `auth:login:ip:${ip}%`, + `auth:login:identifier:${identifier}%` + ]); + } +}); + +test("user write rate limit blocks when threshold is exceeded", async t => { + if (!hasDb) { + t.skip("DATABASE_URL not set"); + return; + } + if (envLoaded.error) t.diagnostic(String(envLoaded.error)); + + await ensureRateLimitTable(); + const pool = getPool(); + const userId = 987654; + const scope = `test_scope_${Date.now()}`; + try { + await enforceUserWriteRateLimit({ + userId, + scope, + limit: 1, + windowMs: 60_000 + }); + + await assert.rejects( + () => enforceUserWriteRateLimit({ + userId, + scope, + limit: 1, + windowMs: 60_000 + }), + (error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED" + ); + } finally { + await pool.query("delete from rate_limits where key = $1", [ + `write:user:${userId}:scope:${scope}` + ]); + } +}); diff --git a/apps/web/__tests__/recurring-entries.test.ts b/apps/web/__tests__/recurring-entries.test.ts index 4e725fa..23b46bc 100644 --- a/apps/web/__tests__/recurring-entries.test.ts +++ b/apps/web/__tests__/recurring-entries.test.ts @@ -69,7 +69,7 @@ test("recurring entries list", async t => { } finally { if (groupId) { const list = await listRecurringEntries(groupId); - for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId }); + for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId, userId: userId! }); } await cleanupTestData(client, { userIds: [userId], groupId }); client.release(); diff --git a/apps/web/__tests__/spendings.test.ts b/apps/web/__tests__/spendings.test.ts index 890e684..f5220ef 100644 --- a/apps/web/__tests__/spendings.test.ts +++ b/apps/web/__tests__/spendings.test.ts @@ -88,7 +88,7 @@ test("entries CRUD", async t => { assert.equal(updated?.entryType, "INCOME"); assert.deepEqual(updated?.tags.sort(), ["groceries"]); - await deleteEntry({ id: entry.id, groupId }); + await deleteEntry({ id: entry.id, groupId, userId }); const listAfter = await listEntries(groupId); assert.equal(listAfter.length, 0); } finally { diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts index 03b4895..d9636c4 100644 --- a/apps/web/app/api/auth/login/route.ts +++ b/apps/web/app/api/auth/login/route.ts @@ -2,25 +2,28 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { getSessionCookieName } from "@/lib/server/auth"; import { loginUser } from "@/lib/server/auth-service"; +import { enforceAuthRateLimit } from "@/lib/server/rate-limit"; import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; export async function POST(req: Request) { + const { requestId, ip } = await getRequestMeta(); const body = await req.json().catch(() => null); const email = String(body?.email || "").trim().toLowerCase(); const password = String(body?.password || ""); const remember = Boolean(body?.remember ?? true); - if (!email || !password) - return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 }); - let user; let session; try { + await enforceAuthRateLimit({ route: "login", ip, identifier: email }); + if (!email || !password) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 }); const result = await loginUser({ email, password, remember }); user = result.user; session = result.session; } catch (e) { - const { status, body } = toErrorResponse(e, "POST /api/auth/login"); + const { status, body } = toErrorResponse(e, "POST /api/auth/login", requestId); return NextResponse.json(body, { status }); } const cookieStore = await cookies(); @@ -32,5 +35,5 @@ export async function POST(req: Request) { path: "/" }); - return NextResponse.json({ user }); + return NextResponse.json({ requestId, request_id: requestId, user }); } diff --git a/apps/web/app/api/auth/logout/route.ts b/apps/web/app/api/auth/logout/route.ts index a99475e..7e93948 100644 --- a/apps/web/app/api/auth/logout/route.ts +++ b/apps/web/app/api/auth/logout/route.ts @@ -2,19 +2,27 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { getSessionCookieName } from "@/lib/server/auth"; import { logoutUser } from "@/lib/server/auth-service"; +import { getRequestMeta } from "@/lib/server/request"; +import { toErrorResponse } from "@/lib/server/errors"; export async function POST() { - const cookieStore = await cookies(); - const token = cookieStore.get(getSessionCookieName())?.value; - if (token) - await logoutUser(token); - cookieStore.set(getSessionCookieName(), "", { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - maxAge: 0, - path: "/" - }); + const { requestId } = await getRequestMeta(); + try { + const cookieStore = await cookies(); + const token = cookieStore.get(getSessionCookieName())?.value; + if (token) + await logoutUser(token); + cookieStore.set(getSessionCookieName(), "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 0, + path: "/" + }); - return NextResponse.json({ ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); + } catch (e) { + const { status, body } = toErrorResponse(e, "POST /api/auth/logout", requestId); + return NextResponse.json(body, { status }); + } } diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts index 84a6f2e..ecb9fb9 100644 --- a/apps/web/app/api/auth/me/route.ts +++ b/apps/web/app/api/auth/me/route.ts @@ -1,9 +1,17 @@ import { NextResponse } from "next/server"; import { getSessionUser } from "@/lib/server/session"; +import { getRequestMeta } from "@/lib/server/request"; +import { toErrorResponse } from "@/lib/server/errors"; export async function GET() { - const user = await getSessionUser(); - if (!user) - return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 }); - return NextResponse.json({ user }); + const { requestId } = await getRequestMeta(); + try { + const user = await getSessionUser(); + if (!user) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 }); + return NextResponse.json({ requestId, request_id: requestId, user }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/auth/me", requestId); + return NextResponse.json(body, { status }); + } } diff --git a/apps/web/app/api/auth/register/route.ts b/apps/web/app/api/auth/register/route.ts index 1fd8089..ded9d01 100644 --- a/apps/web/app/api/auth/register/route.ts +++ b/apps/web/app/api/auth/register/route.ts @@ -2,27 +2,30 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth"; import { registerUser } from "@/lib/server/auth-service"; +import { enforceAuthRateLimit } from "@/lib/server/rate-limit"; import { toErrorResponse } from "@/lib/server/errors"; +import { getRequestMeta } from "@/lib/server/request"; export async function POST(req: Request) { + const { requestId, ip } = await getRequestMeta(); const body = await req.json().catch(() => null); const email = String(body?.email || "").trim().toLowerCase(); const password = String(body?.password || ""); const displayName = String(body?.displayName || "").trim(); - if (!email || !email.includes("@")) - return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 }); - if (password.length < 8) - return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 }); - let user; let session; try { + await enforceAuthRateLimit({ route: "register", ip, identifier: email }); + if (!email || !email.includes("@")) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 }); + if (password.length < 8) + return NextResponse.json({ requestId, request_id: requestId, error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 }); const result = await registerUser({ email, password, displayName }); user = result.user; session = result.session; } catch (e) { - const { status, body } = toErrorResponse(e, "POST /api/auth/register"); + const { status, body } = toErrorResponse(e, "POST /api/auth/register", requestId); return NextResponse.json(body, { status }); } const cookieStore = await cookies(); @@ -34,5 +37,5 @@ export async function POST(req: Request) { path: "/" }); - return NextResponse.json({ user }); + return NextResponse.json({ requestId, request_id: requestId, user }); } diff --git a/apps/web/app/api/buckets/[id]/route.ts b/apps/web/app/api/buckets/[id]/route.ts index bd9cb82..9d233a6 100644 --- a/apps/web/app/api/buckets/[id]/route.ts +++ b/apps/web/app/api/buckets/[id]/route.ts @@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const groupId = await requireActiveGroup(user.id); const { id: idParam } = await params; const id = Number(idParam || 0); - if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); const body = await req.json().catch(() => null); const name = String(body?.name || "").trim(); @@ -30,13 +30,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30; if (!name) - return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 }); if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0)) - return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 }); if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) - return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) - return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 }); const bucket = await updateBucket({ id, @@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", windowDays }); - if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + if (!bucket) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); - return NextResponse.json({ requestId, bucket }); + return NextResponse.json({ requestId, request_id: requestId, bucket }); } catch (e) { const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId); return NextResponse.json(body, { status }); @@ -67,10 +67,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str const groupId = await requireActiveGroup(user.id); const { id: idParam } = await params; const id = Number(idParam || 0); - if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); - await deleteBucket({ id, groupId }); - return NextResponse.json({ requestId, ok: true }); + await deleteBucket({ id, groupId, userId: user.id }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId); return NextResponse.json(body, { status }); diff --git a/apps/web/app/api/buckets/route.ts b/apps/web/app/api/buckets/route.ts index ba25c8c..f7d9c60 100644 --- a/apps/web/app/api/buckets/route.ts +++ b/apps/web/app/api/buckets/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets"; import { toErrorResponse } from "@/lib/server/errors"; @@ -16,7 +16,7 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const buckets = await listBuckets(groupId); - return NextResponse.json({ requestId, buckets }); + return NextResponse.json({ requestId, request_id: requestId, buckets }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId); return NextResponse.json(body, { status }); @@ -38,13 +38,13 @@ export async function POST(req: Request) { const necessity = String(body?.necessity || "BOTH").toUpperCase(); const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30; - if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 }); + if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 }); if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0)) - return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 }); if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) - return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) - return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 }); const bucket = await createBucket({ groupId, @@ -59,9 +59,10 @@ export async function POST(req: Request) { windowDays }); - return NextResponse.json({ requestId, bucket }); + return NextResponse.json({ requestId, request_id: requestId, bucket }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/entries/[id]/route.ts b/apps/web/app/api/entries/[id]/route.ts index 99f623f..2b19a80 100644 --- a/apps/web/app/api/entries/[id]/route.ts +++ b/apps/web/app/api/entries/[id]/route.ts @@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const groupId = await requireActiveGroup(user.id); const { id: idParam } = await params; const id = Number(idParam || 0); - if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); const body = await req.json().catch(() => null); const amountDollars = Number(body?.amountDollars || 0); @@ -37,19 +37,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; if (!Number.isFinite(amountDollars) || amountDollars <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); - if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); - if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) - return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!['SPENDING', 'INCOME'].includes(entryType)) - return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); if (!Number.isFinite(intervalCount) || intervalCount <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) - return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) - return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); const entry = await updateEntry({ id, @@ -72,9 +72,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st bucketId }); - if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); - return NextResponse.json({ requestId, entry }); + return NextResponse.json({ requestId, request_id: requestId, entry }); } catch (e) { const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId); return NextResponse.json(body, { status }); @@ -88,10 +88,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str const groupId = await requireActiveGroup(user.id); const { id: idParam } = await params; const id = Number(idParam || 0); - if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); - await deleteEntry({ id, groupId }); - return NextResponse.json({ requestId, ok: true }); + await deleteEntry({ id, groupId, userId: user.id }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId); return NextResponse.json(body, { status }); diff --git a/apps/web/app/api/entries/route.ts b/apps/web/app/api/entries/route.ts index fbc9efe..c8f397b 100644 --- a/apps/web/app/api/entries/route.ts +++ b/apps/web/app/api/entries/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries"; import { toErrorResponse } from "@/lib/server/errors"; @@ -16,7 +16,7 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const entries = await listEntries(groupId); - return NextResponse.json({ requestId, entries }); + return NextResponse.json({ requestId, request_id: requestId, entries }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/entries", requestId); return NextResponse.json(body, { status }); @@ -46,19 +46,19 @@ export async function POST(req: Request) { const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; if (!Number.isFinite(amountDollars) || amountDollars <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); - if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); - if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) - return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!['SPENDING', 'INCOME'].includes(entryType)) - return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); if (!Number.isFinite(intervalCount) || intervalCount <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) - return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) - return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); const entry = await createEntry({ groupId, @@ -80,9 +80,10 @@ export async function POST(req: Request) { bucketId }); - return NextResponse.json({ requestId, entry }); + return NextResponse.json({ requestId, request_id: requestId, entry }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/entries", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/active/route.ts b/apps/web/app/api/groups/active/route.ts index 6bc9fb9..8c5e31d 100644 --- a/apps/web/app/api/groups/active/route.ts +++ b/apps/web/app/api/groups/active/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups"; import { toErrorResponse } from "@/lib/server/errors"; @@ -11,7 +11,7 @@ export async function GET() { const groups = await listGroups(user.id); const activeGroupId = await getActiveGroupId(user.id); const active = groups.find(group => Number(group.id) === activeGroupId) || null; - return NextResponse.json({ requestId, active }); + return NextResponse.json({ requestId, request_id: requestId, active }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId); return NextResponse.json(body, { status }); @@ -24,12 +24,13 @@ export async function POST(req: Request) { const user = await requireSessionUser(); const body = await req.json().catch(() => null); const groupId = Number(body?.groupId || 0); - if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 }); + if (!groupId) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 }); await setActiveGroupForUser(user.id, groupId); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/audit/route.ts b/apps/web/app/api/groups/audit/route.ts index 371356b..b31b69d 100644 --- a/apps/web/app/api/groups/audit/route.ts +++ b/apps/web/app/api/groups/audit/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { listGroupAudit } from "@/lib/server/group-audit"; @@ -11,9 +11,10 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const events = await listGroupAudit({ userId: user.id, groupId }); - return NextResponse.json({ requestId, events }); + return NextResponse.json({ requestId, request_id: requestId, events }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/delete/route.ts b/apps/web/app/api/groups/delete/route.ts index 42252e0..5b8b236 100644 --- a/apps/web/app/api/groups/delete/route.ts +++ b/apps/web/app/api/groups/delete/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup, deleteGroup } from "@/lib/server/groups"; import { toErrorResponse } from "@/lib/server/errors"; @@ -10,9 +10,10 @@ export async function POST() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/invites/delete/route.ts b/apps/web/app/api/groups/invites/delete/route.ts index 3c4cc88..07ba034 100644 --- a/apps/web/app/api/groups/invites/delete/route.ts +++ b/apps/web/app/api/groups/invites/delete/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { deleteInviteLink } from "@/lib/server/group-invites"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const linkId = Number(body?.linkId || 0); if (!linkId) - return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/invites/revive/route.ts b/apps/web/app/api/groups/invites/revive/route.ts index 0797a44..da2d52d 100644 --- a/apps/web/app/api/groups/invites/revive/route.ts +++ b/apps/web/app/api/groups/invites/revive/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { reviveInviteLink } from "@/lib/server/group-invites"; @@ -14,14 +14,15 @@ export async function POST(req: Request) { const linkId = Number(body?.linkId || 0); const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0))); if (!linkId) - return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); if (!ttlDays) - return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 }); const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000); await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/invites/revoke/route.ts b/apps/web/app/api/groups/invites/revoke/route.ts index 7abe454..e841799 100644 --- a/apps/web/app/api/groups/invites/revoke/route.ts +++ b/apps/web/app/api/groups/invites/revoke/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { revokeInviteLink } from "@/lib/server/group-invites"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const linkId = Number(body?.linkId || 0); if (!linkId) - return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 }); await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/invites/route.ts b/apps/web/app/api/groups/invites/route.ts index cafecd0..9c75d0b 100644 --- a/apps/web/app/api/groups/invites/route.ts +++ b/apps/web/app/api/groups/invites/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites"; @@ -11,7 +11,7 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const links = await listInviteLinks({ userId: user.id, groupId }); - return NextResponse.json({ requestId, links }); + return NextResponse.json({ requestId, request_id: requestId, links }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId); return NextResponse.json(body, { status }); @@ -30,12 +30,13 @@ export async function POST(req: Request) { const singleUse = Boolean(body?.singleUse); const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0))); if (!ttlDays) - return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 }); const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000); const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent }); - return NextResponse.json({ requestId, link }); + return NextResponse.json({ requestId, request_id: requestId, link }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/join/route.ts b/apps/web/app/api/groups/join/route.ts index 736957c..aa3bed9 100644 --- a/apps/web/app/api/groups/join/route.ts +++ b/apps/web/app/api/groups/join/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { joinGroup } from "@/lib/server/groups"; import { toErrorResponse } from "@/lib/server/errors"; @@ -11,12 +11,13 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const inviteCode = String(body?.inviteCode || "").trim().toUpperCase(); if (!inviteCode) - return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 }); const group = await joinGroup(user.id, inviteCode); - return NextResponse.json({ requestId, group }); + return NextResponse.json({ requestId, request_id: requestId, group }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/approve/route.ts b/apps/web/app/api/groups/members/approve/route.ts index 00afe00..b3a7b51 100644 --- a/apps/web/app/api/groups/members/approve/route.ts +++ b/apps/web/app/api/groups/members/approve/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { approveJoinRequest } from "@/lib/server/group-members"; @@ -14,11 +14,12 @@ export async function POST(req: Request) { const userId = Number(body?.userId || 0); const joinRequestId = Number(body?.requestId || 0); if (!userId) - return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/demote/route.ts b/apps/web/app/api/groups/members/demote/route.ts index 2b03bf2..8cb6015 100644 --- a/apps/web/app/api/groups/members/demote/route.ts +++ b/apps/web/app/api/groups/members/demote/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { demoteAdmin } from "@/lib/server/group-members"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const userId = Number(body?.userId || 0); if (!userId) - return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/deny/route.ts b/apps/web/app/api/groups/members/deny/route.ts index 145a781..0d5dbbb 100644 --- a/apps/web/app/api/groups/members/deny/route.ts +++ b/apps/web/app/api/groups/members/deny/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { denyJoinRequest } from "@/lib/server/group-members"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const userId = Number(body?.userId || 0); if (!userId) - return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/kick/route.ts b/apps/web/app/api/groups/members/kick/route.ts index 3d60c5d..8e819e3 100644 --- a/apps/web/app/api/groups/members/kick/route.ts +++ b/apps/web/app/api/groups/members/kick/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { kickMember } from "@/lib/server/group-members"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const userId = Number(body?.userId || 0); if (!userId) - return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/leave/route.ts b/apps/web/app/api/groups/members/leave/route.ts index 99a0cc4..9237b79 100644 --- a/apps/web/app/api/groups/members/leave/route.ts +++ b/apps/web/app/api/groups/members/leave/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { leaveGroup } from "@/lib/server/group-members"; @@ -11,9 +11,10 @@ export async function POST() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/promote/route.ts b/apps/web/app/api/groups/members/promote/route.ts index 937b638..1981db1 100644 --- a/apps/web/app/api/groups/members/promote/route.ts +++ b/apps/web/app/api/groups/members/promote/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { promoteToAdmin } from "@/lib/server/group-members"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const userId = Number(body?.userId || 0); if (!userId) - return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/route.ts b/apps/web/app/api/groups/members/route.ts index 65ad203..1b7ab9f 100644 --- a/apps/web/app/api/groups/members/route.ts +++ b/apps/web/app/api/groups/members/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members"; @@ -12,9 +12,10 @@ export async function GET() { const groupId = await requireActiveGroup(user.id); const members = await listGroupMembers(groupId); const requests = await listJoinRequests({ userId: user.id, groupId }); - return NextResponse.json({ requestId, members, requests, currentUserId: user.id }); + return NextResponse.json({ requestId, request_id: requestId, members, requests, currentUserId: user.id }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/members/transfer-owner/route.ts b/apps/web/app/api/groups/members/transfer-owner/route.ts index eee725e..270e3e6 100644 --- a/apps/web/app/api/groups/members/transfer-owner/route.ts +++ b/apps/web/app/api/groups/members/transfer-owner/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { transferOwnership } from "@/lib/server/group-members"; @@ -13,11 +13,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const userId = Number(body?.userId || 0); if (!userId) - return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 }); await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/rename/route.ts b/apps/web/app/api/groups/rename/route.ts index 010e47a..d515307 100644 --- a/apps/web/app/api/groups/rename/route.ts +++ b/apps/web/app/api/groups/rename/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup, renameGroup } from "@/lib/server/groups"; import { toErrorResponse } from "@/lib/server/errors"; @@ -12,11 +12,12 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const name = String(body?.name || "").trim(); if (!name) - return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 }); await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/route.ts b/apps/web/app/api/groups/route.ts index 19d33cb..bb8bea3 100644 --- a/apps/web/app/api/groups/route.ts +++ b/apps/web/app/api/groups/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { createGroup, listGroups } from "@/lib/server/groups"; import { toErrorResponse } from "@/lib/server/errors"; @@ -9,7 +9,7 @@ export async function GET() { try { const user = await requireSessionUser(); const groups = await listGroups(user.id); - return NextResponse.json({ requestId, groups }); + return NextResponse.json({ requestId, request_id: requestId, groups }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/groups", requestId); return NextResponse.json(body, { status }); @@ -22,12 +22,13 @@ export async function POST(req: Request) { const user = await requireSessionUser(); const body = await req.json().catch(() => null); const name = String(body?.name || "").trim(); - if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 }); + if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 }); const group = await createGroup(user.id, name); - return NextResponse.json({ requestId, group }); + return NextResponse.json({ requestId, request_id: requestId, group }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/groups/settings/route.ts b/apps/web/app/api/groups/settings/route.ts index 1287f0c..b37a3a8 100644 --- a/apps/web/app/api/groups/settings/route.ts +++ b/apps/web/app/api/groups/settings/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings"; @@ -11,7 +11,7 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const settings = await getGroupSettings(groupId); - return NextResponse.json({ requestId, settings }); + return NextResponse.json({ requestId, request_id: requestId, settings }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId); return NextResponse.json(body, { status }); @@ -30,9 +30,10 @@ export async function POST(req: Request) { : "NOT_ACCEPTING"; await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy }); const settings = await getGroupSettings(groupId); - return NextResponse.json({ requestId, settings }); + return NextResponse.json({ requestId, request_id: requestId, settings }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/health/live/route.ts b/apps/web/app/api/health/live/route.ts new file mode 100644 index 0000000..e9cd01b --- /dev/null +++ b/apps/web/app/api/health/live/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { getRequestMeta } from "@/lib/server/request"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + return NextResponse.json({ + requestId, + request_id: requestId, + ok: true, + status: "live" + }); +} diff --git a/apps/web/app/api/health/ready/route.ts b/apps/web/app/api/health/ready/route.ts new file mode 100644 index 0000000..687872a --- /dev/null +++ b/apps/web/app/api/health/ready/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import getPool from "@/lib/server/db"; +import { getRequestMeta } from "@/lib/server/request"; +import { toErrorResponse } from "@/lib/server/errors"; + +export async function GET() { + const { requestId } = await getRequestMeta(); + try { + const pool = getPool(); + await pool.query("select 1"); + return NextResponse.json({ + requestId, + request_id: requestId, + ok: true, + status: "ready" + }); + } catch (e) { + const { status, body } = toErrorResponse(e, "GET /api/health/ready", requestId); + return NextResponse.json({ + ...body, + ok: false, + status: "not_ready" + }, { status }); + } +} diff --git a/apps/web/app/api/invite-links/[token]/route.ts b/apps/web/app/api/invite-links/[token]/route.ts index c585b30..89fbb8a 100644 --- a/apps/web/app/api/invite-links/[token]/route.ts +++ b/apps/web/app/api/invite-links/[token]/route.ts @@ -16,9 +16,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string if (user) { const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId }); if (viewerStatus) - return NextResponse.json({ requestId, link: { ...link, viewerStatus } }); + return NextResponse.json({ requestId, request_id: requestId, link: { ...link, viewerStatus } }); } - return NextResponse.json({ requestId, link }); + return NextResponse.json({ requestId, request_id: requestId, link }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId); return NextResponse.json(body, { status }); @@ -33,7 +33,7 @@ export async function POST(_: Request, context: { params: Promise<{ token: strin const normalized = String(token || "").trim(); if (!normalized) apiError("INVITE_NOT_FOUND"); const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent }); - return NextResponse.json({ requestId, result }); + return NextResponse.json({ requestId, request_id: requestId, result }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId); return NextResponse.json(body, { status }); diff --git a/apps/web/app/api/recurring-entries/[id]/route.ts b/apps/web/app/api/recurring-entries/[id]/route.ts index a9905ab..ae35a52 100644 --- a/apps/web/app/api/recurring-entries/[id]/route.ts +++ b/apps/web/app/api/recurring-entries/[id]/route.ts @@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const groupId = await requireActiveGroup(user.id); const { id: idParam } = await params; const id = Number(idParam || 0); - if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); const body = await req.json().catch(() => null); const amountDollars = Number(body?.amountDollars || 0); @@ -36,19 +36,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; if (!Number.isFinite(amountDollars) || amountDollars <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); - if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); - if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) - return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!['SPENDING', 'INCOME'].includes(entryType)) - return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); if (!Number.isFinite(intervalCount) || intervalCount <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) - return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) - return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); const entry = await updateRecurringEntry({ id, @@ -71,9 +71,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st bucketId }); - if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); + if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); - return NextResponse.json({ requestId, entry }); + return NextResponse.json({ requestId, request_id: requestId, entry }); } catch (e) { const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId); return NextResponse.json(body, { status }); @@ -87,10 +87,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str const groupId = await requireActiveGroup(user.id); const { id: idParam } = await params; const id = Number(idParam || 0); - if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); + if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 }); - await deleteRecurringEntry({ id, groupId }); - return NextResponse.json({ requestId, ok: true }); + await deleteRecurringEntry({ id, groupId, userId: user.id }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId); return NextResponse.json(body, { status }); diff --git a/apps/web/app/api/recurring-entries/route.ts b/apps/web/app/api/recurring-entries/route.ts index 80fff1d..0a86a6a 100644 --- a/apps/web/app/api/recurring-entries/route.ts +++ b/apps/web/app/api/recurring-entries/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries"; import { toErrorResponse } from "@/lib/server/errors"; @@ -16,7 +16,7 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const entries = await listRecurringEntries(groupId); - return NextResponse.json({ requestId, entries }); + return NextResponse.json({ requestId, request_id: requestId, entries }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId); return NextResponse.json(body, { status }); @@ -45,19 +45,19 @@ export async function POST(req: Request) { const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; if (!Number.isFinite(amountDollars) || amountDollars <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); - if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); - if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); + if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 }); + if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 }); if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity)) - return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); if (!['SPENDING', 'INCOME'].includes(entryType)) - return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); if (!Number.isFinite(intervalCount) || intervalCount <= 0) - return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) - return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) - return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); + return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); const entry = await createRecurringEntry({ groupId, @@ -79,9 +79,10 @@ export async function POST(req: Request) { bucketId }); - return NextResponse.json({ requestId, entry }); + return NextResponse.json({ requestId, request_id: requestId, entry }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/app/api/tags/[name]/route.ts b/apps/web/app/api/tags/[name]/route.ts index 6076c41..95bc404 100644 --- a/apps/web/app/api/tags/[name]/route.ts +++ b/apps/web/app/api/tags/[name]/route.ts @@ -12,7 +12,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ name: s const groupId = await requireActiveGroup(user.id); const { name } = await params; await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) }); - return NextResponse.json({ requestId, ok: true }); + return NextResponse.json({ requestId, request_id: requestId, ok: true }); } catch (e) { const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId); return NextResponse.json(body, { status }); diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts index a67c667..1edc033 100644 --- a/apps/web/app/api/tags/route.ts +++ b/apps/web/app/api/tags/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { requireSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/entries"; import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags"; @@ -11,7 +11,7 @@ export async function GET() { const user = await requireSessionUser(); const groupId = await requireActiveGroup(user.id); const tags = await listGroupTags(groupId); - return NextResponse.json({ requestId, tags }); + return NextResponse.json({ requestId, request_id: requestId, tags }); } catch (e) { const { status, body } = toErrorResponse(e, "GET /api/tags", requestId); return NextResponse.json(body, { status }); @@ -27,9 +27,10 @@ export async function POST(req: Request) { const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : []; await ensureTagsForGroup({ userId: user.id, groupId, tags }); const list = await listGroupTags(groupId); - return NextResponse.json({ requestId, tags: list }); + return NextResponse.json({ requestId, request_id: requestId, tags: list }); } catch (e) { const { status, body } = toErrorResponse(e, "POST /api/tags", requestId); return NextResponse.json(body, { status }); } } + diff --git a/apps/web/lib/server/auth-service.ts b/apps/web/lib/server/auth-service.ts index 9b4e379..96a9632 100644 --- a/apps/web/lib/server/auth-service.ts +++ b/apps/web/lib/server/auth-service.ts @@ -8,7 +8,7 @@ export async function registerUser(input: { email: string; password: string; dis const pool = getPool(); const email = input.email.trim().toLowerCase(); const existing = await pool.query("select id from users where email=$1", [email]); - if (existing.rowCount) + if (existing.rows.length > 0) apiError("EMAIL_EXISTS", { email }); const passwordHash = await hashPassword(input.password); diff --git a/apps/web/lib/server/buckets.ts b/apps/web/lib/server/buckets.ts index 75a31c7..0650fb3 100644 --- a/apps/web/lib/server/buckets.ts +++ b/apps/web/lib/server/buckets.ts @@ -4,6 +4,7 @@ import getPool from "@/lib/server/db"; import { requireActiveGroup } from "@/lib/server/groups"; import { ensureTagsForGroup, listTagsForBuckets, normalizeTags, setBucketTags } from "@/lib/server/tags"; import { calculateBucketUsage } from "@/lib/shared/bucket-usage"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; export { requireActiveGroup }; @@ -108,6 +109,7 @@ export async function createBucket(input: { necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY"; windowDays?: number; }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:create" }); const pool = getPool(); const rows = (await pool.query( `insert into buckets(group_id, created_by, name, description, icon_key, budget_limit_dollars, position, necessity, window_days) @@ -157,6 +159,7 @@ export async function updateBucket(input: { necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY"; windowDays?: number; }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:update" }); const pool = getPool(); const rows = (await pool.query( `update buckets @@ -200,7 +203,8 @@ export async function updateBucket(input: { } as Bucket; } -export async function deleteBucket(input: { id: number; groupId: number }) { +export async function deleteBucket(input: { id: number; groupId: number; userId: number }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:delete" }); const pool = getPool(); await pool.query("delete from buckets where id=$1 and group_id=$2", [input.id, input.groupId]); } diff --git a/apps/web/lib/server/db.ts b/apps/web/lib/server/db.ts index 6e1c431..0b861fa 100644 --- a/apps/web/lib/server/db.ts +++ b/apps/web/lib/server/db.ts @@ -1,12 +1,12 @@ if (process.env.NODE_ENV !== "test") require("server-only"); -import pg from "pg"; +import pg, { type Pool as PgPool } from "pg"; const { Pool } = pg; declare global { // eslint-disable-next-line no-var - var __fiddyPool: pg.Pool | undefined; + var __fiddyPool: PgPool | undefined; } export default function getPool() { diff --git a/apps/web/lib/server/entries.ts b/apps/web/lib/server/entries.ts index 555e316..c7758d6 100644 --- a/apps/web/lib/server/entries.ts +++ b/apps/web/lib/server/entries.ts @@ -4,6 +4,7 @@ import getPool from "@/lib/server/db"; import { requireActiveGroup } from "@/lib/server/groups"; import { listTagsForEntries, normalizeTags, requireExistingTagsForGroup, setEntryTags } from "@/lib/server/tags"; import { listBucketTags } from "@/lib/server/buckets"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; import type { Entry } from "@/lib/shared/types"; type EntryRow = { @@ -48,6 +49,7 @@ export async function listEntries(groupId: number): Promise { purchaseType: row.purchase_type, notes: row.notes, receiptId: row.receipt_id, + bucketId: null, tags: tagsMap.get(Number(row.id)) || [], isRecurring: row.is_recurring, frequency: row.frequency, @@ -79,6 +81,7 @@ export async function createEntry(input: { nextRunAt?: string | null; bucketId?: number | null; }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" }); const pool = getPool(); const rows = (await pool.query( `insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, @@ -118,6 +121,7 @@ export async function createEntry(input: { purchaseType: row.purchase_type, notes: row.notes, receiptId: row.receipt_id, + bucketId: null, tags, isRecurring: row.is_recurring, frequency: row.frequency, @@ -150,6 +154,7 @@ export async function updateEntry(input: { nextRunAt?: string | null; bucketId?: number | null; }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" }); const pool = getPool(); const rows = (await pool.query( `update entries @@ -190,6 +195,7 @@ export async function updateEntry(input: { purchaseType: rows[0].purchase_type, notes: rows[0].notes, receiptId: rows[0].receipt_id, + bucketId: null, tags, isRecurring: rows[0].is_recurring, frequency: rows[0].frequency, @@ -202,7 +208,8 @@ export async function updateEntry(input: { } as Entry; } -export async function deleteEntry(input: { id: number; groupId: number }) { +export async function deleteEntry(input: { id: number; groupId: number; userId: number }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:delete" }); const pool = getPool(); await pool.query("delete from entries where id=$1 and group_id=$2", [input.id, input.groupId]); } diff --git a/apps/web/lib/server/errors.ts b/apps/web/lib/server/errors.ts index 030f6ec..bd384a0 100644 --- a/apps/web/lib/server/errors.ts +++ b/apps/web/lib/server/errors.ts @@ -12,6 +12,8 @@ type ErrorPayload = { const sensitiveKeys = new Set([ "password", "token", + "invitecode", + "invite_code", "authorization", "cookie", "session", @@ -37,7 +39,8 @@ const statusMap: Record = { OWNER_MUST_TRANSFER: { status: 403, message: "Owner must transfer ownership before leaving" }, CANNOT_LEAVE_LAST_MEMBER: { status: 403, message: "Cannot leave last remaining member" }, EMAIL_EXISTS: { status: 409, message: "Email already registered" }, - INVALID_CREDENTIALS: { status: 401, message: "Invalid credentials" } + INVALID_CREDENTIALS: { status: 401, message: "Invalid credentials" }, + RATE_LIMITED: { status: 429, message: "Too many requests" } }; export class ApiError extends Error { @@ -76,7 +79,6 @@ function logApiError(payload: ErrorPayload) { export function toErrorResponse(e: unknown, route: string, requestId?: string) { const debugEnabled = process.env.DEBUG_API === "1"; - console.log() let payload: ErrorPayload = { code: "SERVER_ERROR", message: "Server error" }; let status = 500; @@ -100,6 +102,7 @@ export function toErrorResponse(e: unknown, route: string, requestId?: string) { status, body: { requestId, + request_id: requestId, error: { code: payload.code, message: payload.message }, ...(debugEnabled ? { debug: payload.context } : {}) } diff --git a/apps/web/lib/server/group-invites.ts b/apps/web/lib/server/group-invites.ts index c78c256..d09a32d 100644 --- a/apps/web/lib/server/group-invites.ts +++ b/apps/web/lib/server/group-invites.ts @@ -5,6 +5,7 @@ import getPool from "@/lib/server/db"; import { apiError } from "@/lib/server/errors"; import { requireGroupAdmin } from "@/lib/server/group-access"; import { recordGroupAudit } from "@/lib/server/group-audit"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; export type JoinPolicy = "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED"; @@ -41,12 +42,12 @@ export async function getInviteViewerStatus(input: { userId: number; groupId: nu "select 1 from group_members where group_id=$1 and user_id=$2", [input.groupId, input.userId] ); - if (existing.rowCount) return "ALREADY_MEMBER" as const; + if (existing.rows.length) return "ALREADY_MEMBER" as const; const pending = await pool.query( "select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'", [input.groupId, input.userId] ); - if (pending.rowCount) return "PENDING" as const; + if (pending.rows.length) return "PENDING" as const; return null; } @@ -80,6 +81,7 @@ export async function createInviteLink(input: { ip?: string | null; userAgent?: string | null; }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:create" }); const role = await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const token = createToken(); @@ -111,6 +113,7 @@ export async function createInviteLink(input: { token: String(row.token), policy: row.policy as JoinPolicy, singleUse: Boolean(row.single_use), + groupJoinPolicy: row.policy as JoinPolicy, expiresAt: row.expires_at, usedAt: row.used_at, revokedAt: row.revoked_at, @@ -134,6 +137,7 @@ export async function listInviteLinks(input: { userId: number; groupId: number } token: String(row.token), policy: row.policy as JoinPolicy, singleUse: Boolean(row.single_use), + groupJoinPolicy: row.policy as JoinPolicy, expiresAt: row.expires_at, usedAt: row.used_at, revokedAt: row.revoked_at, @@ -142,6 +146,7 @@ export async function listInviteLinks(input: { userId: number; groupId: number } } export async function revokeInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:revoke" }); const role = await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const { rows } = await pool.query( @@ -165,6 +170,7 @@ export async function revokeInviteLink(input: { userId: number; groupId: number; } export async function reviveInviteLink(input: { userId: number; groupId: number; linkId: number; expiresAt: Date; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:revive" }); const role = await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const { rows } = await pool.query( @@ -208,6 +214,7 @@ export async function getInviteLinkByToken(token: string) { token: String(row.token), policy: row.policy as JoinPolicy, singleUse: Boolean(row.single_use), + groupJoinPolicy: row.policy as JoinPolicy, expiresAt: row.expires_at, usedAt: row.used_at, revokedAt: row.revoked_at, @@ -243,6 +250,7 @@ export async function getInviteLinkSummaryByToken(token: string) { } export async function acceptInviteLink(input: { userId: number; token: string; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:accept" }); const summary = await getInviteLinkSummaryByToken(input.token); if (!summary) apiError("INVITE_NOT_FOUND", { tokenLast4: last4(input.token) }); @@ -251,7 +259,7 @@ export async function acceptInviteLink(input: { userId: number; token: string; r "select 1 from group_members where group_id=$1 and user_id=$2", [summary.groupId, input.userId] ); - if (existing.rowCount) { + if (existing.rows.length) { return { status: "ALREADY_MEMBER" as const, group: { id: summary.groupId, name: summary.groupName } }; } @@ -259,7 +267,7 @@ export async function acceptInviteLink(input: { userId: number; token: string; r "select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'", [summary.groupId, input.userId] ); - if (pending.rowCount) { + if (pending.rows.length) { return { status: "PENDING" as const, group: { id: summary.groupId, name: summary.groupName } }; } @@ -333,6 +341,7 @@ export async function markInviteLinkUsed(input: { linkId: number }) { } export async function deleteInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:delete" }); const role = await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const { rows } = await pool.query( diff --git a/apps/web/lib/server/group-members.ts b/apps/web/lib/server/group-members.ts index 7e6866a..ae7d86c 100644 --- a/apps/web/lib/server/group-members.ts +++ b/apps/web/lib/server/group-members.ts @@ -4,6 +4,7 @@ import getPool from "@/lib/server/db"; import { apiError } from "@/lib/server/errors"; import { getGroupRole, isAdminRole, requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access"; import { recordGroupAudit } from "@/lib/server/group-audit"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; export type GroupRole = "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER"; @@ -104,6 +105,7 @@ export async function createJoinRequest(input: { userId: number; groupId: number } export async function approveJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null; requestRowId?: number }) { + await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:approve" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const client = await pool.connect(); @@ -161,15 +163,16 @@ export async function approveJoinRequest(input: { actorUserId: number; groupId: } export async function denyJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:deny" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); - const { rowCount } = await pool.query( + const { rows } = await pool.query( `update group_join_requests set status='DENIED', decided_by=$3, decided_at=now(), updated_at=now() where group_id=$1 and user_id=$2 and status='PENDING'`, [input.groupId, input.userId, input.actorUserId] ); - if (!rowCount) apiError("JOIN_REQUEST_NOT_FOUND", { groupId: input.groupId, userId: input.userId }); + if (!rows.length) apiError("JOIN_REQUEST_NOT_FOUND", { groupId: input.groupId, userId: input.userId }); await recordGroupAudit({ groupId: input.groupId, @@ -184,6 +187,7 @@ export async function denyJoinRequest(input: { actorUserId: number; groupId: num } export async function kickMember(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:kick" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const client = await pool.connect(); @@ -229,6 +233,7 @@ export async function kickMember(input: { actorUserId: number; groupId: number; } export async function leaveGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:members:leave" }); const pool = getPool(); const client = await pool.connect(); let role: GroupRole | null = null; @@ -273,6 +278,7 @@ export async function leaveGroup(input: { userId: number; groupId: number; reque } export async function promoteToAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:promote" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const { rows } = await pool.query( @@ -301,6 +307,7 @@ export async function promoteToAdmin(input: { actorUserId: number; groupId: numb } export async function demoteAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:demote" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const { rows } = await pool.query( @@ -330,6 +337,7 @@ export async function demoteAdmin(input: { actorUserId: number; groupId: number; } export async function transferOwnership(input: { actorUserId: number; groupId: number; newOwnerUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:transfer-owner" }); await requireGroupOwner(input.actorUserId, input.groupId); const pool = getPool(); const client = await pool.connect(); diff --git a/apps/web/lib/server/group-settings.ts b/apps/web/lib/server/group-settings.ts index ea0a035..c034d7c 100644 --- a/apps/web/lib/server/group-settings.ts +++ b/apps/web/lib/server/group-settings.ts @@ -3,6 +3,7 @@ if (process.env.NODE_ENV !== "test") import getPool from "@/lib/server/db"; import { apiError } from "@/lib/server/errors"; import { requireGroupAdmin } from "@/lib/server/group-access"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; export async function getGroupSettings(groupId: number) { const pool = getPool(); @@ -17,6 +18,7 @@ export async function getGroupSettings(groupId: number) { } export async function setGroupSettings(input: { userId: number; groupId: number; allowMemberTagManage: boolean; joinPolicy?: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED" }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:settings:update" }); await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); diff --git a/apps/web/lib/server/groups.ts b/apps/web/lib/server/groups.ts index 4795446..bb8d005 100644 --- a/apps/web/lib/server/groups.ts +++ b/apps/web/lib/server/groups.ts @@ -6,11 +6,16 @@ import { apiError } from "@/lib/server/errors"; import type { Group } from "@/lib/shared/types"; import { requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access"; import { recordGroupAudit } from "@/lib/server/group-audit"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; function createInviteCode() { return crypto.randomBytes(4).toString("hex").toUpperCase(); } +function last4(value: string) { + return value.slice(-4); +} + type GroupRow = { id: number; name: string; @@ -56,16 +61,18 @@ export async function setActiveGroupId(userId: number, groupId: number) { } export async function setActiveGroupForUser(userId: number, groupId: number) { + await enforceUserWriteRateLimit({ userId, scope: "groups:active:set" }); const pool = getPool(); - const { rowCount } = await pool.query( + const { rows } = await pool.query( "select 1 from group_members where group_id=$1 and user_id=$2", [groupId, userId] ); - if (!rowCount) apiError("FORBIDDEN", { userId, groupId }); + if (!rows.length) apiError("FORBIDDEN", { userId, groupId }); await setActiveGroupId(userId, groupId); } export async function createGroup(userId: number, name: string) { + await enforceUserWriteRateLimit({ userId, scope: "groups:create" }); const pool = getPool(); const inviteCode = createInviteCode(); const { rows } = await pool.query( @@ -85,6 +92,7 @@ export async function createGroup(userId: number, name: string) { } export async function renameGroup(input: { userId: number; groupId: number; name: string; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:rename" }); const role = await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const { rows } = await pool.query( @@ -105,6 +113,7 @@ export async function renameGroup(input: { userId: number; groupId: number; name } export async function deleteGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:delete" }); await requireGroupOwner(input.userId, input.groupId); const pool = getPool(); await pool.query( @@ -123,6 +132,7 @@ export async function deleteGroup(input: { userId: number; groupId: number; requ } export async function joinGroup(userId: number, inviteCode: string) { + await enforceUserWriteRateLimit({ userId, scope: "groups:join" }); const pool = getPool(); const { rows } = await pool.query( "select id, name from groups where invite_code=$1", @@ -130,13 +140,13 @@ export async function joinGroup(userId: number, inviteCode: string) { ); const group = rows[0]; if (!group) - apiError("INVALID_INVITE", { inviteCode }); + apiError("INVALID_INVITE", { inviteCodeLast4: last4(inviteCode) }); const existing = await pool.query( "select 1 from group_members where group_id=$1 and user_id=$2", [group.id, userId] ); - if (existing.rowCount) + if (existing.rows.length) return { id: group.id, name: group.name } as { id: number; name: string }; const settings = await pool.query( diff --git a/apps/web/lib/server/rate-limit.ts b/apps/web/lib/server/rate-limit.ts new file mode 100644 index 0000000..40636ba --- /dev/null +++ b/apps/web/lib/server/rate-limit.ts @@ -0,0 +1,105 @@ +if (process.env.NODE_ENV !== "test") + require("server-only"); +import getPool from "@/lib/server/db"; +import { apiError } from "@/lib/server/errors"; + +type LimitInput = { + key: string; + limit: number; + windowMs: number; + scope: string; +}; + +let ensureTablePromise: Promise | null = null; + +async function ensureRateLimitsTable() { + if (!ensureTablePromise) { + ensureTablePromise = (async () => { + const pool = getPool(); + await pool.query(` + create table if not exists rate_limits( + key text primary key, + window_start timestamptz not null, + count integer not null default 0, + updated_at timestamptz not null default now() + ) + `); + await pool.query("create index if not exists rate_limits_updated_at_idx on rate_limits(updated_at)"); + })(); + } + await ensureTablePromise; +} + +function normalizeWindowStart(nowMs: number, windowMs: number) { + const bucketStart = Math.floor(nowMs / windowMs) * windowMs; + return new Date(bucketStart); +} + +async function consumeRateLimit(input: LimitInput) { + await ensureRateLimitsTable(); + const now = Date.now(); + const windowStart = normalizeWindowStart(now, input.windowMs); + const pool = getPool(); + const { rows } = await pool.query<{ count: number }>( + `insert into rate_limits(key, window_start, count, updated_at) + values($1, $2, 1, now()) + on conflict (key) do update + set count = case + when rate_limits.window_start = excluded.window_start then rate_limits.count + 1 + else 1 + end, + window_start = case + when rate_limits.window_start = excluded.window_start then rate_limits.window_start + else excluded.window_start + end, + updated_at = now() + returning count`, + [input.key, windowStart] + ); + const count = Number(rows[0]?.count || 0); + if (count > input.limit) { + apiError("RATE_LIMITED", { + scope: input.scope, + limit: input.limit, + windowMs: input.windowMs + }); + } +} + +export async function enforceAuthRateLimit(input: { + route: "login" | "register"; + ip?: string | null; + identifier?: string | null; + ipLimit?: number; + identifierLimit?: number; + windowMs?: number; +}) { + const scope = `auth:${input.route}`; + const ip = String(input.ip || "unknown").trim().toLowerCase(); + const windowMs = input.windowMs ?? (15 * 60 * 1000); + await consumeRateLimit({ + key: `${scope}:ip:${ip}`, + scope, + limit: input.ipLimit ?? 20, + windowMs + }); + + const identifier = String(input.identifier || "").trim().toLowerCase(); + if (identifier) { + await consumeRateLimit({ + key: `${scope}:identifier:${identifier}`, + scope, + limit: input.identifierLimit ?? 10, + windowMs + }); + } +} + +export async function enforceUserWriteRateLimit(input: { userId: number; scope: string; limit?: number; windowMs?: number }) { + await consumeRateLimit({ + key: `write:user:${input.userId}:scope:${input.scope}`, + scope: input.scope, + limit: input.limit ?? 120, + windowMs: input.windowMs ?? (15 * 60 * 1000) + }); +} diff --git a/apps/web/lib/server/recurring-entries.ts b/apps/web/lib/server/recurring-entries.ts index 1fca525..df8dd6b 100644 --- a/apps/web/lib/server/recurring-entries.ts +++ b/apps/web/lib/server/recurring-entries.ts @@ -18,6 +18,6 @@ export async function updateRecurringEntry(input: Parameters return updateEntry({ ...input, isRecurring: true }); } -export async function deleteRecurringEntry(input: { id: number; groupId: number }) { +export async function deleteRecurringEntry(input: { id: number; groupId: number; userId: number }) { return deleteEntry(input); } diff --git a/apps/web/lib/server/tags.ts b/apps/web/lib/server/tags.ts index 24a3f30..e23aae7 100644 --- a/apps/web/lib/server/tags.ts +++ b/apps/web/lib/server/tags.ts @@ -3,6 +3,7 @@ if (process.env.NODE_ENV !== "test") import getPool from "@/lib/server/db"; import { apiError } from "@/lib/server/errors"; import { getGroupRole, isAdminRole } from "@/lib/server/group-access"; +import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; type TagRow = { id: number; name: string }; type TagListRow = { name: string }; @@ -44,6 +45,7 @@ export async function canManageTags(userId: number, groupId: number) { } export async function deleteTagForGroup(input: { userId: number; groupId: number; name: string }) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "tags:delete" }); const allowed = await canManageTags(input.userId, input.groupId); if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId }); const pool = getPool(); @@ -67,6 +69,7 @@ export async function ensureTagsForGroup(input: { userId: number; groupId: numbe const missing = tags.filter(tag => !existing.has(tag)); if (missing.length) { + await enforceUserWriteRateLimit({ userId: input.userId, scope: "tags:create" }); const allowed = await canManageTags(input.userId, input.groupId); if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId, missing }); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index ff8b4c5..1008e22 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1 +1,21 @@ -export default {}; +const securityHeaders = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Content-Security-Policy", value: "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" } +]; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + async headers() { + return [ + { + source: "/:path*", + headers: securityHeaders + } + ]; + } +}; + +export default nextConfig; diff --git a/docker/nginx/fiddy.conf b/docker/nginx/fiddy.conf new file mode 100644 index 0000000..ce8a571 --- /dev/null +++ b/docker/nginx/fiddy.conf @@ -0,0 +1,59 @@ +limit_req_zone $binary_remote_addr zone=fiddy_auth:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=fiddy_write:10m rate=60r/m; + +upstream fiddy_web { + server 127.0.0.1:3000; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name fiddy.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name fiddy.example.com; + + ssl_certificate /etc/letsencrypt/live/fiddy.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/fiddy.example.com/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + client_max_body_size 10m; + client_body_timeout 15s; + client_header_timeout 15s; + keepalive_timeout 30s; + send_timeout 30s; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location /api/auth/login { + limit_req zone=fiddy_auth burst=15 nodelay; + include /etc/nginx/includes/fiddy-proxy.conf; + } + + location /api/auth/register { + limit_req zone=fiddy_auth burst=15 nodelay; + include /etc/nginx/includes/fiddy-proxy.conf; + } + + location ~ ^/api/(entries|buckets|groups|tags|recurring-entries) { + if ($request_method ~* "(POST|PATCH|PUT|DELETE)") { + limit_req zone=fiddy_write burst=40 nodelay; + } + include /etc/nginx/includes/fiddy-proxy.conf; + } + + location / { + include /etc/nginx/includes/fiddy-proxy.conf; + } +} diff --git a/docker/nginx/includes/fiddy-proxy.conf b/docker/nginx/includes/fiddy-proxy.conf new file mode 100644 index 0000000..9673dee --- /dev/null +++ b/docker/nginx/includes/fiddy-proxy.conf @@ -0,0 +1,10 @@ +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header Connection ""; +proxy_read_timeout 60s; +proxy_send_timeout 60s; +proxy_connect_timeout 5s; +proxy_pass http://fiddy_web; diff --git a/docker/observability/docker-compose.observability.yml b/docker/observability/docker-compose.observability.yml new file mode 100644 index 0000000..d6cefe0 --- /dev/null +++ b/docker/observability/docker-compose.observability.yml @@ -0,0 +1,53 @@ +services: + loki: + image: grafana/loki:3.2.0 + command: -config.file=/etc/loki/local-config.yml + volumes: + - ./loki-config.yml:/etc/loki/local-config.yml:ro + - loki_data:/loki + ports: + - "3100:3100" + restart: unless-stopped + + promtail: + image: grafana/promtail:3.2.0 + command: -config.file=/etc/promtail/config.yml + volumes: + - ./promtail-config.yml:/etc/promtail/config.yml:ro + - /var/log:/var/log:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + restart: unless-stopped + depends_on: + - loki + + grafana: + image: grafana/grafana:11.3.0 + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=change-me + ports: + - "3001:3000" + volumes: + - grafana_data:/var/lib/grafana + restart: unless-stopped + depends_on: + - loki + + uptime-kuma: + image: louislam/uptime-kuma:1.23.16 + ports: + - "3002:3001" + volumes: + - uptime_kuma_data:/app/data + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.8.2 + ports: + - "9100:9100" + restart: unless-stopped + +volumes: + loki_data: + grafana_data: + uptime_kuma_data: diff --git a/docker/observability/loki-config.yml b/docker/observability/loki-config.yml new file mode 100644 index 0000000..1b46106 --- /dev/null +++ b/docker/observability/loki-config.yml @@ -0,0 +1,31 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h + +ruler: + alertmanager_url: http://localhost:9093 diff --git a/docker/observability/promtail-config.yml b/docker/observability/promtail-config.yml new file mode 100644 index 0000000..f558a1e --- /dev/null +++ b/docker/observability/promtail-config.yml @@ -0,0 +1,26 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: system + static_configs: + - targets: + - localhost + labels: + job: varlogs + __path__: /var/log/*log + + - job_name: docker + static_configs: + - targets: + - localhost + labels: + job: docker + __path__: /var/lib/docker/containers/*/*-json.log diff --git a/docs/05_REFACTOR_2.md b/docs/05_REFACTOR_2.md new file mode 100644 index 0000000..5eb26b1 --- /dev/null +++ b/docs/05_REFACTOR_2.md @@ -0,0 +1,68 @@ +# Refactor 2: Public Launch Hardening + +## Purpose Overview +This refactor prepares Fiddy for public exposure without changing the core stack (Next.js + external Postgres). The goal is to harden API contracts, improve abuse resistance, tighten security posture, and make deployment/operations repeatable for self-hosted production. + +Primary outcomes: +- Keep architecture stable and avoid risky stack rewrite. +- Standardize API response metadata (`request_id`) for traceability. +- Add layered rate limiting (app + proxy plan) for auth and write paths. +- Remove sensitive logging risk (no full invite codes, no secrets). +- Add health probes and deployment/ops reference artifacts. +- Document rollout, rollback, backup, and monitoring runbooks. + +## Scope Checklist +- [x] Phase 1: App security + API contract hardening +- [x] Phase 2: Dokploy deployment workflow artifacts +- [x] Phase 3: Nginx + host hardening artifacts +- [x] Phase 4: Observability stack references/configs +- [x] Phase 5: Backup + restore process and scripts/docs +- [x] Verification pass (tests/build/lint where possible) + +## Running Implementation Log + +### 2026-02-14 +- Started `Refactor 2` execution from current in-progress workspace baseline. +- Confirmed this repo already contains broad uncommitted changes unrelated to this phase; implementation will be done via targeted edits only. +- Established this document as the source log/checklist for all noteworthy decisions, tradeoffs, blockers, and completed steps. +- Added migration `packages/db/migrations/007_rate_limits.sql` for server-side rate limit state. +- Added server limiter `apps/web/lib/server/rate-limit.ts` and wired write-path guardrails in server services (`groups`, `group-members`, `group-invites`, `entries`, `buckets`, `tags`, `group-settings`). +- Hardened API error pipeline in `apps/web/lib/server/errors.ts`: + - Added `RATE_LIMITED` mapping (`429`). + - Added `request_id` alias in structured error body. + - Extended sensitive-key redaction for invite code keys. + - Removed stray debug `console.log()` call. +- Fixed invite-code leak risk in `apps/web/lib/server/groups.ts` by sending only `inviteCodeLast4` in error context. +- Standardized API response envelope updates in auth and route handlers so responses include both `requestId` and `request_id` while preserving backward compatibility. +- Added health probe routes: + - `apps/web/app/api/health/live/route.ts` + - `apps/web/app/api/health/ready/route.ts` +- Added security header baseline in `apps/web/next.config.mjs` (CSP, frame/referrer/content-type hardening). +- Added CI/CD workflow for Dokploy-triggered deploys: + - `.gitea/workflows/deploy-dokploy.yml` +- Added self-host edge + observability + backup artifacts: + - `docker/nginx/fiddy.conf` + - `docker/nginx/includes/fiddy-proxy.conf` + - `docker/observability/docker-compose.observability.yml` + - `docker/observability/loki-config.yml` + - `docker/observability/promtail-config.yml` + - `scripts/backup-postgres.sh` + - `scripts/restore-postgres.sh` + - `docs/public-launch-runbook.md` +- Added regression tests for request-id contract and limiter behavior: + - `apps/web/__tests__/errors-response.test.ts` + - `apps/web/__tests__/rate-limit.test.ts` +- Verification results: + - `npm test`: pass (`25 passed`, `1 skipped`). + - `npm run build`: pass. + - `npm run lint`: still fails due existing workspace lint script invocation issue (`next lint` resolves `apps/web/lint` path). +- Post-verification fixups: + - Added table auto-bootstrap fallback in `apps/web/lib/server/rate-limit.ts` to avoid failures in environments where migration `007_rate_limits.sql` has not been applied yet. + - Corrected `Entry` mapping consistency in `apps/web/lib/server/entries.ts` (`bucketId` included in all return shapes). + - Replaced `rowCount` checks with `rows.length` in typed query paths to satisfy current TypeScript/`pg` typings. +- Implementation correction note: + - A batch replacement briefly introduced invalid destructuring (`request_id` in `getRequestMeta` destructure). This was corrected in all affected routes before final verification. + +### Risks / Notes to Revisit +- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes. +- `npm run lint` currently fails due `next lint` invocation behavior in this environment; lint verification needs explicit follow-up task. diff --git a/docs/public-launch-runbook.md b/docs/public-launch-runbook.md new file mode 100644 index 0000000..7731fff --- /dev/null +++ b/docs/public-launch-runbook.md @@ -0,0 +1,65 @@ +# Public Launch Runbook (Self-Hosted + Dokploy) + +## 1) Goals +- Deploy Fiddy publicly without stack rewrite. +- Keep Postgres self-hosted. +- Enable fast rollback and basic operational visibility. +- Keep security baseline enforceable for direct home-IP exposure. + +## 2) Deploy Control Plane (Dokploy) +1. Install Dokploy on your Proxmox Docker host. +2. Add project in Dokploy and connect Gitea repository. +3. Configure image source: `git.nicosaya.com/nalalangan/fiddy/web`. +4. Deploy by immutable tag (`github.sha`) and keep `main` as convenience tag. +5. Configure health check endpoint: `/api/health/ready`. +6. Keep previous releases for rollback and verify rollback button path. + +### Required secrets/variables +- `DATABASE_URL` +- `DATABASE_SSL` +- `ALLOWED_DB_NAMES` +- `SESSION_COOKIE_NAME` +- `SESSION_TTL_DAYS` +- `DEBUG_API=0` + +## 3) CI/CD (Gitea Actions) +- Use `.gitea/workflows/deploy-dokploy.yml`. +- Required secrets: + - `REGISTRY_USER` + - `REGISTRY_PASS` + - `DOKPLOY_DEPLOY_HOOK` + +## 4) Reverse Proxy + Network Hardening +- Use `docker/nginx/fiddy.conf` as baseline. +- Install certificate with Let’s Encrypt. +- Route 443 -> app container only. +- Keep Postgres private; never expose 5432 publicly. +- Restrict SSH to allowlist/VPN. +- Add host firewall rules: + - Allow inbound `80/443`. + - Deny all other inbound by default. + +## 5) Observability +- Bring up monitoring stack: + - `docker compose -f docker/observability/docker-compose.observability.yml up -d` +- Configure Grafana datasource to Loki (`http://loki:3100`). +- Add Uptime Kuma monitors: + - `/api/health/live` + - `/api/health/ready` + - home page (`/`) + +## 6) Backup + Restore +- Daily backup command: + - `scripts/backup-postgres.sh` +- Retention: + - default 7 days (`RETENTION_DAYS=7`) +- Restore drill: + - `scripts/restore-postgres.sh backups/postgres/.dump ` +- Run restore drill on non-prod DB before public launch. + +## 7) Incident Response Quick Flow +1. Identify failing request and `request_id`. +2. Correlate application logs (Loki) by `request_id`. +3. Check `/api/health/ready` status and DB connectivity. +4. Roll back to previous known-good Dokploy release if needed. +5. Capture root cause and update this runbook/checklist. diff --git a/packages/db/migrations/007_rate_limits.sql b/packages/db/migrations/007_rate_limits.sql new file mode 100644 index 0000000..f93d8de --- /dev/null +++ b/packages/db/migrations/007_rate_limits.sql @@ -0,0 +1,8 @@ +create table if not exists rate_limits( + key text primary key, + window_start timestamptz not null, + count integer not null default 0, + updated_at timestamptz not null default now() +); + +create index if not exists rate_limits_updated_at_idx on rate_limits(updated_at); diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100644 index 0000000..40886a1 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "DATABASE_URL is required" + exit 1 +fi + +BACKUP_DIR="${BACKUP_DIR:-./backups/postgres}" +RETENTION_DAYS="${RETENTION_DAYS:-7}" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" + +mkdir -p "$BACKUP_DIR" + +echo "Creating logical backup..." +pg_dump "$DATABASE_URL" --format=custom --file="${BACKUP_DIR}/fiddy_${TIMESTAMP}.dump" + +echo "Creating globals backup..." +pg_dumpall "$DATABASE_URL" --globals-only > "${BACKUP_DIR}/fiddy_globals_${TIMESTAMP}.sql" + +echo "Pruning backups older than ${RETENTION_DAYS} days..." +find "$BACKUP_DIR" -type f -name "fiddy_*" -mtime "+${RETENTION_DAYS}" -delete + +echo "Backup complete: ${BACKUP_DIR}" diff --git a/scripts/restore-postgres.sh b/scripts/restore-postgres.sh new file mode 100644 index 0000000..dac3624 --- /dev/null +++ b/scripts/restore-postgres.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + exit 1 +fi + +BACKUP_FILE="$1" +TARGET_DATABASE_URL="$2" + +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "Backup file not found: $BACKUP_FILE" + exit 1 +fi + +echo "Restoring backup ${BACKUP_FILE} into target database..." +pg_restore --clean --if-exists --no-owner --no-privileges --dbname="$TARGET_DATABASE_URL" "$BACKUP_FILE" +echo "Restore complete."