harden public launch api contracts and ops baseline

This commit is contained in:
Nico 2026-02-14 00:39:20 -08:00
parent 4873449e16
commit b1c8a4ae6c
63 changed files with 968 additions and 181 deletions

View File

@ -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\"}"

View File

@ -75,7 +75,7 @@ test("buckets CRUD", async t => {
assert.equal(updated?.name, "Groceries+"); assert.equal(updated?.name, "Groceries+");
assert.deepEqual(updated?.tags.sort(), ["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); const listAfter = await listBuckets(groupId);
assert.equal(listAfter.length, 0); assert.equal(listAfter.length, 0);
} finally { } finally {

View File

@ -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;
});

View File

@ -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}`
]);
}
});

View File

@ -69,7 +69,7 @@ test("recurring entries list", async t => {
} finally { } finally {
if (groupId) { if (groupId) {
const list = await listRecurringEntries(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 }); await cleanupTestData(client, { userIds: [userId], groupId });
client.release(); client.release();

View File

@ -88,7 +88,7 @@ test("entries CRUD", async t => {
assert.equal(updated?.entryType, "INCOME"); assert.equal(updated?.entryType, "INCOME");
assert.deepEqual(updated?.tags.sort(), ["groceries"]); assert.deepEqual(updated?.tags.sort(), ["groceries"]);
await deleteEntry({ id: entry.id, groupId }); await deleteEntry({ id: entry.id, groupId, userId });
const listAfter = await listEntries(groupId); const listAfter = await listEntries(groupId);
assert.equal(listAfter.length, 0); assert.equal(listAfter.length, 0);
} finally { } finally {

View File

@ -2,25 +2,28 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getSessionCookieName } from "@/lib/server/auth"; import { getSessionCookieName } from "@/lib/server/auth";
import { loginUser } from "@/lib/server/auth-service"; import { loginUser } from "@/lib/server/auth-service";
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) { export async function POST(req: Request) {
const { requestId, ip } = await getRequestMeta();
const body = await req.json().catch(() => null); const body = await req.json().catch(() => null);
const email = String(body?.email || "").trim().toLowerCase(); const email = String(body?.email || "").trim().toLowerCase();
const password = String(body?.password || ""); const password = String(body?.password || "");
const remember = Boolean(body?.remember ?? true); const remember = Boolean(body?.remember ?? true);
if (!email || !password)
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
let user; let user;
let session; let session;
try { 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 }); const result = await loginUser({ email, password, remember });
user = result.user; user = result.user;
session = result.session; session = result.session;
} catch (e) { } 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 }); return NextResponse.json(body, { status });
} }
const cookieStore = await cookies(); const cookieStore = await cookies();
@ -32,5 +35,5 @@ export async function POST(req: Request) {
path: "/" path: "/"
}); });
return NextResponse.json({ user }); return NextResponse.json({ requestId, request_id: requestId, user });
} }

View File

@ -2,8 +2,12 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getSessionCookieName } from "@/lib/server/auth"; import { getSessionCookieName } from "@/lib/server/auth";
import { logoutUser } from "@/lib/server/auth-service"; import { logoutUser } from "@/lib/server/auth-service";
import { getRequestMeta } from "@/lib/server/request";
import { toErrorResponse } from "@/lib/server/errors";
export async function POST() { export async function POST() {
const { requestId } = await getRequestMeta();
try {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(getSessionCookieName())?.value; const token = cookieStore.get(getSessionCookieName())?.value;
if (token) if (token)
@ -16,5 +20,9 @@ export async function POST() {
path: "/" 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 });
}
} }

View File

@ -1,9 +1,17 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/server/session"; import { getSessionUser } from "@/lib/server/session";
import { getRequestMeta } from "@/lib/server/request";
import { toErrorResponse } from "@/lib/server/errors";
export async function GET() { export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
return NextResponse.json({ user }); 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 });
}
} }

View File

@ -2,27 +2,30 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth"; import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
import { registerUser } from "@/lib/server/auth-service"; import { registerUser } from "@/lib/server/auth-service";
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) { export async function POST(req: Request) {
const { requestId, ip } = await getRequestMeta();
const body = await req.json().catch(() => null); const body = await req.json().catch(() => null);
const email = String(body?.email || "").trim().toLowerCase(); const email = String(body?.email || "").trim().toLowerCase();
const password = String(body?.password || ""); const password = String(body?.password || "");
const displayName = String(body?.displayName || "").trim(); 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 user;
let session; let session;
try { 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 }); const result = await registerUser({ email, password, displayName });
user = result.user; user = result.user;
session = result.session; session = result.session;
} catch (e) { } 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 }); return NextResponse.json(body, { status });
} }
const cookieStore = await cookies(); const cookieStore = await cookies();
@ -34,5 +37,5 @@ export async function POST(req: Request) {
path: "/" path: "/"
}); });
return NextResponse.json({ user }); return NextResponse.json({ requestId, request_id: requestId, user });
} }

View File

@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params; const { id: idParam } = await params;
const id = Number(idParam || 0); 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 body = await req.json().catch(() => null);
const name = String(body?.name || "").trim(); 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; const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
if (!name) 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)) 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)) 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) 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({ const bucket = await updateBucket({
id, id,
@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId); const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
return NextResponse.json(body, { status }); 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 groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params; const { id: idParam } = await params;
const id = Number(idParam || 0); 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 }); await deleteBucket({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, ok: true }); return NextResponse.json({ requestId, request_id: requestId, ok: true });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId); const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets"; import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -16,7 +16,7 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const buckets = await listBuckets(groupId); const buckets = await listBuckets(groupId);
return NextResponse.json({ requestId, buckets }); return NextResponse.json({ requestId, request_id: requestId, buckets });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId); const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -38,13 +38,13 @@ export async function POST(req: Request) {
const necessity = String(body?.necessity || "BOTH").toUpperCase(); const necessity = String(body?.necessity || "BOTH").toUpperCase();
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30; 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)) 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)) 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) 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({ const bucket = await createBucket({
groupId, groupId,
@ -59,9 +59,10 @@ export async function POST(req: Request) {
windowDays windowDays
}); });
return NextResponse.json({ requestId, bucket }); return NextResponse.json({ requestId, request_id: requestId, bucket });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId); const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params; const { id: idParam } = await params;
const id = Number(idParam || 0); 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 body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0); 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; const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: 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 (!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, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType 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)) 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)) 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) 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)) 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)) 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({ const entry = await updateEntry({
id, id,
@ -72,9 +72,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
bucketId 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId); const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
return NextResponse.json(body, { status }); 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 groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params; const { id: idParam } = await params;
const id = Number(idParam || 0); 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 }); await deleteEntry({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, ok: true }); return NextResponse.json({ requestId, request_id: requestId, ok: true });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId); const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries"; import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -16,7 +16,7 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const entries = await listEntries(groupId); const entries = await listEntries(groupId);
return NextResponse.json({ requestId, entries }); return NextResponse.json({ requestId, request_id: requestId, entries });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId); const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -46,19 +46,19 @@ export async function POST(req: Request) {
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: 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 (!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, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType 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)) 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)) 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) 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)) 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)) 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({ const entry = await createEntry({
groupId, groupId,
@ -80,9 +80,10 @@ export async function POST(req: Request) {
bucketId bucketId
}); });
return NextResponse.json({ requestId, entry }); return NextResponse.json({ requestId, request_id: requestId, entry });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId); const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups"; import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -11,7 +11,7 @@ export async function GET() {
const groups = await listGroups(user.id); const groups = await listGroups(user.id);
const activeGroupId = await getActiveGroupId(user.id); const activeGroupId = await getActiveGroupId(user.id);
const active = groups.find(group => Number(group.id) === activeGroupId) || null; 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId); const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -24,12 +24,13 @@ export async function POST(req: Request) {
const user = await requireSessionUser(); const user = await requireSessionUser();
const body = await req.json().catch(() => null); const body = await req.json().catch(() => null);
const groupId = Number(body?.groupId || 0); 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); await setActiveGroupForUser(user.id, groupId);
return NextResponse.json({ requestId, ok: true }); return NextResponse.json({ requestId, request_id: requestId, ok: true });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { listGroupAudit } from "@/lib/server/group-audit"; import { listGroupAudit } from "@/lib/server/group-audit";
@ -11,9 +11,10 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const events = await listGroupAudit({ userId: user.id, groupId }); const events = await listGroupAudit({ userId: user.id, groupId });
return NextResponse.json({ requestId, events }); return NextResponse.json({ requestId, request_id: requestId, events });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId); const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups"; import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -10,9 +10,10 @@ export async function POST() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { deleteInviteLink } from "@/lib/server/group-invites"; 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 body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0); const linkId = Number(body?.linkId || 0);
if (!linkId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { reviveInviteLink } from "@/lib/server/group-invites"; import { reviveInviteLink } from "@/lib/server/group-invites";
@ -14,14 +14,15 @@ export async function POST(req: Request) {
const linkId = Number(body?.linkId || 0); const linkId = Number(body?.linkId || 0);
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0))); const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
if (!linkId) 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) 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 expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { revokeInviteLink } from "@/lib/server/group-invites"; 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 body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0); const linkId = Number(body?.linkId || 0);
if (!linkId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites"; import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
@ -11,7 +11,7 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const links = await listInviteLinks({ userId: user.id, groupId }); const links = await listInviteLinks({ userId: user.id, groupId });
return NextResponse.json({ requestId, links }); return NextResponse.json({ requestId, request_id: requestId, links });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId); const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -30,12 +30,13 @@ export async function POST(req: Request) {
const singleUse = Boolean(body?.singleUse); const singleUse = Boolean(body?.singleUse);
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0))); const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
if (!ttlDays) 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 expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { joinGroup } from "@/lib/server/groups"; import { joinGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -11,12 +11,13 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null); const body = await req.json().catch(() => null);
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase(); const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
if (!inviteCode) 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); const group = await joinGroup(user.id, inviteCode);
return NextResponse.json({ requestId, group }); return NextResponse.json({ requestId, request_id: requestId, group });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { approveJoinRequest } from "@/lib/server/group-members"; import { approveJoinRequest } from "@/lib/server/group-members";
@ -14,11 +14,12 @@ export async function POST(req: Request) {
const userId = Number(body?.userId || 0); const userId = Number(body?.userId || 0);
const joinRequestId = Number(body?.requestId || 0); const joinRequestId = Number(body?.requestId || 0);
if (!userId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { demoteAdmin } from "@/lib/server/group-members"; 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 body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0); const userId = Number(body?.userId || 0);
if (!userId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { denyJoinRequest } from "@/lib/server/group-members"; 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 body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0); const userId = Number(body?.userId || 0);
if (!userId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { kickMember } from "@/lib/server/group-members"; 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 body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0); const userId = Number(body?.userId || 0);
if (!userId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { leaveGroup } from "@/lib/server/group-members"; import { leaveGroup } from "@/lib/server/group-members";
@ -11,9 +11,10 @@ export async function POST() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { promoteToAdmin } from "@/lib/server/group-members"; 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 body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0); const userId = Number(body?.userId || 0);
if (!userId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members"; import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
@ -12,9 +12,10 @@ export async function GET() {
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const members = await listGroupMembers(groupId); const members = await listGroupMembers(groupId);
const requests = await listJoinRequests({ userId: user.id, 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId); const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { transferOwnership } from "@/lib/server/group-members"; 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 body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0); const userId = Number(body?.userId || 0);
if (!userId) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, renameGroup } from "@/lib/server/groups"; import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -12,11 +12,12 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null); const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim(); const name = String(body?.name || "").trim();
if (!name) 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 }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { createGroup, listGroups } from "@/lib/server/groups"; import { createGroup, listGroups } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -9,7 +9,7 @@ export async function GET() {
try { try {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groups = await listGroups(user.id); const groups = await listGroups(user.id);
return NextResponse.json({ requestId, groups }); return NextResponse.json({ requestId, request_id: requestId, groups });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId); const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -22,12 +22,13 @@ export async function POST(req: Request) {
const user = await requireSessionUser(); const user = await requireSessionUser();
const body = await req.json().catch(() => null); const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim(); 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); const group = await createGroup(user.id, name);
return NextResponse.json({ requestId, group }); return NextResponse.json({ requestId, request_id: requestId, group });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings"; import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
@ -11,7 +11,7 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const settings = await getGroupSettings(groupId); const settings = await getGroupSettings(groupId);
return NextResponse.json({ requestId, settings }); return NextResponse.json({ requestId, request_id: requestId, settings });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId); const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -30,9 +30,10 @@ export async function POST(req: Request) {
: "NOT_ACCEPTING"; : "NOT_ACCEPTING";
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy }); await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
const settings = await getGroupSettings(groupId); const settings = await getGroupSettings(groupId);
return NextResponse.json({ requestId, settings }); return NextResponse.json({ requestId, request_id: requestId, settings });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId); const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -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"
});
}

View File

@ -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 });
}
}

View File

@ -16,9 +16,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
if (user) { if (user) {
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId }); const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
if (viewerStatus) 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId); const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -33,7 +33,7 @@ export async function POST(_: Request, context: { params: Promise<{ token: strin
const normalized = String(token || "").trim(); const normalized = String(token || "").trim();
if (!normalized) apiError("INVITE_NOT_FOUND"); if (!normalized) apiError("INVITE_NOT_FOUND");
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId); const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });

View File

@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params; const { id: idParam } = await params;
const id = Number(idParam || 0); 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 body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0); 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; const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: 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 (!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, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType 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)) 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)) 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) 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)) 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)) 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({ const entry = await updateRecurringEntry({
id, id,
@ -71,9 +71,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
bucketId 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId); const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
return NextResponse.json(body, { status }); 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 groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params; const { id: idParam } = await params;
const id = Number(idParam || 0); 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 }); await deleteRecurringEntry({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, ok: true }); return NextResponse.json({ requestId, request_id: requestId, ok: true });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId); const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries"; import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -16,7 +16,7 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const entries = await listRecurringEntries(groupId); const entries = await listRecurringEntries(groupId);
return NextResponse.json({ requestId, entries }); return NextResponse.json({ requestId, request_id: requestId, entries });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId); const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
@ -45,19 +45,19 @@ export async function POST(req: Request) {
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: 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 (!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, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType 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)) 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)) 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) 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)) 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)) 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({ const entry = await createRecurringEntry({
groupId, groupId,
@ -79,9 +79,10 @@ export async function POST(req: Request) {
bucketId bucketId
}); });
return NextResponse.json({ requestId, entry }); return NextResponse.json({ requestId, request_id: requestId, entry });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId); const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -12,7 +12,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ name: s
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const { name } = await params; const { name } = await params;
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) }); 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) { } catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId); const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/entries"; import { requireActiveGroup } from "@/lib/server/entries";
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags"; import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
@ -11,7 +11,7 @@ export async function GET() {
const user = await requireSessionUser(); const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id); const groupId = await requireActiveGroup(user.id);
const tags = await listGroupTags(groupId); const tags = await listGroupTags(groupId);
return NextResponse.json({ requestId, tags }); return NextResponse.json({ requestId, request_id: requestId, tags });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId); const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
return NextResponse.json(body, { status }); 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)) : []; const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
await ensureTagsForGroup({ userId: user.id, groupId, tags }); await ensureTagsForGroup({ userId: user.id, groupId, tags });
const list = await listGroupTags(groupId); const list = await listGroupTags(groupId);
return NextResponse.json({ requestId, tags: list }); return NextResponse.json({ requestId, request_id: requestId, tags: list });
} catch (e) { } catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId); const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
} }

View File

@ -8,7 +8,7 @@ export async function registerUser(input: { email: string; password: string; dis
const pool = getPool(); const pool = getPool();
const email = input.email.trim().toLowerCase(); const email = input.email.trim().toLowerCase();
const existing = await pool.query("select id from users where email=$1", [email]); 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 }); apiError("EMAIL_EXISTS", { email });
const passwordHash = await hashPassword(input.password); const passwordHash = await hashPassword(input.password);

View File

@ -4,6 +4,7 @@ import getPool from "@/lib/server/db";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { ensureTagsForGroup, listTagsForBuckets, normalizeTags, setBucketTags } from "@/lib/server/tags"; import { ensureTagsForGroup, listTagsForBuckets, normalizeTags, setBucketTags } from "@/lib/server/tags";
import { calculateBucketUsage } from "@/lib/shared/bucket-usage"; import { calculateBucketUsage } from "@/lib/shared/bucket-usage";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
export { requireActiveGroup }; export { requireActiveGroup };
@ -108,6 +109,7 @@ export async function createBucket(input: {
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY"; necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
windowDays?: number; windowDays?: number;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:create" });
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<BucketRow>( const rows = (await pool.query<BucketRow>(
`insert into buckets(group_id, created_by, name, description, icon_key, budget_limit_dollars, position, necessity, window_days) `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"; necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
windowDays?: number; windowDays?: number;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:update" });
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<BucketRow>( const rows = (await pool.query<BucketRow>(
`update buckets `update buckets
@ -200,7 +203,8 @@ export async function updateBucket(input: {
} as Bucket; } 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(); const pool = getPool();
await pool.query("delete from buckets where id=$1 and group_id=$2", [input.id, input.groupId]); await pool.query("delete from buckets where id=$1 and group_id=$2", [input.id, input.groupId]);
} }

View File

@ -1,12 +1,12 @@
if (process.env.NODE_ENV !== "test") if (process.env.NODE_ENV !== "test")
require("server-only"); require("server-only");
import pg from "pg"; import pg, { type Pool as PgPool } from "pg";
const { Pool } = pg; const { Pool } = pg;
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var __fiddyPool: pg.Pool | undefined; var __fiddyPool: PgPool | undefined;
} }
export default function getPool() { export default function getPool() {

View File

@ -4,6 +4,7 @@ import getPool from "@/lib/server/db";
import { requireActiveGroup } from "@/lib/server/groups"; import { requireActiveGroup } from "@/lib/server/groups";
import { listTagsForEntries, normalizeTags, requireExistingTagsForGroup, setEntryTags } from "@/lib/server/tags"; import { listTagsForEntries, normalizeTags, requireExistingTagsForGroup, setEntryTags } from "@/lib/server/tags";
import { listBucketTags } from "@/lib/server/buckets"; import { listBucketTags } from "@/lib/server/buckets";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
import type { Entry } from "@/lib/shared/types"; import type { Entry } from "@/lib/shared/types";
type EntryRow = { type EntryRow = {
@ -48,6 +49,7 @@ export async function listEntries(groupId: number): Promise<Entry[]> {
purchaseType: row.purchase_type, purchaseType: row.purchase_type,
notes: row.notes, notes: row.notes,
receiptId: row.receipt_id, receiptId: row.receipt_id,
bucketId: null,
tags: tagsMap.get(Number(row.id)) || [], tags: tagsMap.get(Number(row.id)) || [],
isRecurring: row.is_recurring, isRecurring: row.is_recurring,
frequency: row.frequency, frequency: row.frequency,
@ -79,6 +81,7 @@ export async function createEntry(input: {
nextRunAt?: string | null; nextRunAt?: string | null;
bucketId?: number | null; bucketId?: number | null;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" });
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<EntryRow>( const rows = (await pool.query<EntryRow>(
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, `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, purchaseType: row.purchase_type,
notes: row.notes, notes: row.notes,
receiptId: row.receipt_id, receiptId: row.receipt_id,
bucketId: null,
tags, tags,
isRecurring: row.is_recurring, isRecurring: row.is_recurring,
frequency: row.frequency, frequency: row.frequency,
@ -150,6 +154,7 @@ export async function updateEntry(input: {
nextRunAt?: string | null; nextRunAt?: string | null;
bucketId?: number | null; bucketId?: number | null;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" });
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<EntryRow>( const rows = (await pool.query<EntryRow>(
`update entries `update entries
@ -190,6 +195,7 @@ export async function updateEntry(input: {
purchaseType: rows[0].purchase_type, purchaseType: rows[0].purchase_type,
notes: rows[0].notes, notes: rows[0].notes,
receiptId: rows[0].receipt_id, receiptId: rows[0].receipt_id,
bucketId: null,
tags, tags,
isRecurring: rows[0].is_recurring, isRecurring: rows[0].is_recurring,
frequency: rows[0].frequency, frequency: rows[0].frequency,
@ -202,7 +208,8 @@ export async function updateEntry(input: {
} as Entry; } 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(); const pool = getPool();
await pool.query("delete from entries where id=$1 and group_id=$2", [input.id, input.groupId]); await pool.query("delete from entries where id=$1 and group_id=$2", [input.id, input.groupId]);
} }

View File

@ -12,6 +12,8 @@ type ErrorPayload = {
const sensitiveKeys = new Set([ const sensitiveKeys = new Set([
"password", "password",
"token", "token",
"invitecode",
"invite_code",
"authorization", "authorization",
"cookie", "cookie",
"session", "session",
@ -37,7 +39,8 @@ const statusMap: Record<string, { status: number; message: string }> = {
OWNER_MUST_TRANSFER: { status: 403, message: "Owner must transfer ownership before leaving" }, OWNER_MUST_TRANSFER: { status: 403, message: "Owner must transfer ownership before leaving" },
CANNOT_LEAVE_LAST_MEMBER: { status: 403, message: "Cannot leave last remaining member" }, CANNOT_LEAVE_LAST_MEMBER: { status: 403, message: "Cannot leave last remaining member" },
EMAIL_EXISTS: { status: 409, message: "Email already registered" }, 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 { export class ApiError extends Error {
@ -76,7 +79,6 @@ function logApiError(payload: ErrorPayload) {
export function toErrorResponse(e: unknown, route: string, requestId?: string) { export function toErrorResponse(e: unknown, route: string, requestId?: string) {
const debugEnabled = process.env.DEBUG_API === "1"; const debugEnabled = process.env.DEBUG_API === "1";
console.log()
let payload: ErrorPayload = { code: "SERVER_ERROR", message: "Server error" }; let payload: ErrorPayload = { code: "SERVER_ERROR", message: "Server error" };
let status = 500; let status = 500;
@ -100,6 +102,7 @@ export function toErrorResponse(e: unknown, route: string, requestId?: string) {
status, status,
body: { body: {
requestId, requestId,
request_id: requestId,
error: { code: payload.code, message: payload.message }, error: { code: payload.code, message: payload.message },
...(debugEnabled ? { debug: payload.context } : {}) ...(debugEnabled ? { debug: payload.context } : {})
} }

View File

@ -5,6 +5,7 @@ import getPool from "@/lib/server/db";
import { apiError } from "@/lib/server/errors"; import { apiError } from "@/lib/server/errors";
import { requireGroupAdmin } from "@/lib/server/group-access"; import { requireGroupAdmin } from "@/lib/server/group-access";
import { recordGroupAudit } from "@/lib/server/group-audit"; import { recordGroupAudit } from "@/lib/server/group-audit";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
export type JoinPolicy = "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED"; 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", "select 1 from group_members where group_id=$1 and user_id=$2",
[input.groupId, input.userId] [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( const pending = await pool.query(
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'", "select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
[input.groupId, input.userId] [input.groupId, input.userId]
); );
if (pending.rowCount) return "PENDING" as const; if (pending.rows.length) return "PENDING" as const;
return null; return null;
} }
@ -80,6 +81,7 @@ export async function createInviteLink(input: {
ip?: string | null; ip?: string | null;
userAgent?: string | null; userAgent?: string | null;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:create" });
const role = await requireGroupAdmin(input.userId, input.groupId); const role = await requireGroupAdmin(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();
const token = createToken(); const token = createToken();
@ -111,6 +113,7 @@ export async function createInviteLink(input: {
token: String(row.token), token: String(row.token),
policy: row.policy as JoinPolicy, policy: row.policy as JoinPolicy,
singleUse: Boolean(row.single_use), singleUse: Boolean(row.single_use),
groupJoinPolicy: row.policy as JoinPolicy,
expiresAt: row.expires_at, expiresAt: row.expires_at,
usedAt: row.used_at, usedAt: row.used_at,
revokedAt: row.revoked_at, revokedAt: row.revoked_at,
@ -134,6 +137,7 @@ export async function listInviteLinks(input: { userId: number; groupId: number }
token: String(row.token), token: String(row.token),
policy: row.policy as JoinPolicy, policy: row.policy as JoinPolicy,
singleUse: Boolean(row.single_use), singleUse: Boolean(row.single_use),
groupJoinPolicy: row.policy as JoinPolicy,
expiresAt: row.expires_at, expiresAt: row.expires_at,
usedAt: row.used_at, usedAt: row.used_at,
revokedAt: row.revoked_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 }) { 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 role = await requireGroupAdmin(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( 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 }) { 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 role = await requireGroupAdmin(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( const { rows } = await pool.query(
@ -208,6 +214,7 @@ export async function getInviteLinkByToken(token: string) {
token: String(row.token), token: String(row.token),
policy: row.policy as JoinPolicy, policy: row.policy as JoinPolicy,
singleUse: Boolean(row.single_use), singleUse: Boolean(row.single_use),
groupJoinPolicy: row.policy as JoinPolicy,
expiresAt: row.expires_at, expiresAt: row.expires_at,
usedAt: row.used_at, usedAt: row.used_at,
revokedAt: row.revoked_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 }) { 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); const summary = await getInviteLinkSummaryByToken(input.token);
if (!summary) apiError("INVITE_NOT_FOUND", { tokenLast4: last4(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", "select 1 from group_members where group_id=$1 and user_id=$2",
[summary.groupId, input.userId] [summary.groupId, input.userId]
); );
if (existing.rowCount) { if (existing.rows.length) {
return { status: "ALREADY_MEMBER" as const, group: { id: summary.groupId, name: summary.groupName } }; 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'", "select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
[summary.groupId, input.userId] [summary.groupId, input.userId]
); );
if (pending.rowCount) { if (pending.rows.length) {
return { status: "PENDING" as const, group: { id: summary.groupId, name: summary.groupName } }; 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 }) { 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 role = await requireGroupAdmin(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( const { rows } = await pool.query(

View File

@ -4,6 +4,7 @@ import getPool from "@/lib/server/db";
import { apiError } from "@/lib/server/errors"; import { apiError } from "@/lib/server/errors";
import { getGroupRole, isAdminRole, requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access"; import { getGroupRole, isAdminRole, requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access";
import { recordGroupAudit } from "@/lib/server/group-audit"; import { recordGroupAudit } from "@/lib/server/group-audit";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
export type GroupRole = "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER"; 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 }) { 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 role = await requireGroupAdmin(input.actorUserId, input.groupId);
const pool = getPool(); const pool = getPool();
const client = await pool.connect(); 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 }) { 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 role = await requireGroupAdmin(input.actorUserId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rowCount } = await pool.query( const { rows } = await pool.query(
`update group_join_requests `update group_join_requests
set status='DENIED', decided_by=$3, decided_at=now(), updated_at=now() set status='DENIED', decided_by=$3, decided_at=now(), updated_at=now()
where group_id=$1 and user_id=$2 and status='PENDING'`, where group_id=$1 and user_id=$2 and status='PENDING'`,
[input.groupId, input.userId, input.actorUserId] [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({ await recordGroupAudit({
groupId: input.groupId, 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 }) { 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 role = await requireGroupAdmin(input.actorUserId, input.groupId);
const pool = getPool(); const pool = getPool();
const client = await pool.connect(); 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 }) { 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 pool = getPool();
const client = await pool.connect(); const client = await pool.connect();
let role: GroupRole | null = null; 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 }) { 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 role = await requireGroupAdmin(input.actorUserId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( 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 }) { 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 role = await requireGroupAdmin(input.actorUserId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( 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 }) { 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); await requireGroupOwner(input.actorUserId, input.groupId);
const pool = getPool(); const pool = getPool();
const client = await pool.connect(); const client = await pool.connect();

View File

@ -3,6 +3,7 @@ if (process.env.NODE_ENV !== "test")
import getPool from "@/lib/server/db"; import getPool from "@/lib/server/db";
import { apiError } from "@/lib/server/errors"; import { apiError } from "@/lib/server/errors";
import { requireGroupAdmin } from "@/lib/server/group-access"; import { requireGroupAdmin } from "@/lib/server/group-access";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
export async function getGroupSettings(groupId: number) { export async function getGroupSettings(groupId: number) {
const pool = getPool(); 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" }) { 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); await requireGroupAdmin(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();

View File

@ -6,11 +6,16 @@ import { apiError } from "@/lib/server/errors";
import type { Group } from "@/lib/shared/types"; import type { Group } from "@/lib/shared/types";
import { requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access"; import { requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access";
import { recordGroupAudit } from "@/lib/server/group-audit"; import { recordGroupAudit } from "@/lib/server/group-audit";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
function createInviteCode() { function createInviteCode() {
return crypto.randomBytes(4).toString("hex").toUpperCase(); return crypto.randomBytes(4).toString("hex").toUpperCase();
} }
function last4(value: string) {
return value.slice(-4);
}
type GroupRow = { type GroupRow = {
id: number; id: number;
name: string; name: string;
@ -56,16 +61,18 @@ export async function setActiveGroupId(userId: number, groupId: number) {
} }
export async function setActiveGroupForUser(userId: number, groupId: number) { export async function setActiveGroupForUser(userId: number, groupId: number) {
await enforceUserWriteRateLimit({ userId, scope: "groups:active:set" });
const pool = getPool(); 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", "select 1 from group_members where group_id=$1 and user_id=$2",
[groupId, userId] [groupId, userId]
); );
if (!rowCount) apiError("FORBIDDEN", { userId, groupId }); if (!rows.length) apiError("FORBIDDEN", { userId, groupId });
await setActiveGroupId(userId, groupId); await setActiveGroupId(userId, groupId);
} }
export async function createGroup(userId: number, name: string) { export async function createGroup(userId: number, name: string) {
await enforceUserWriteRateLimit({ userId, scope: "groups:create" });
const pool = getPool(); const pool = getPool();
const inviteCode = createInviteCode(); const inviteCode = createInviteCode();
const { rows } = await pool.query( 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 }) { 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 role = await requireGroupAdmin(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( 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 }) { 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); await requireGroupOwner(input.userId, input.groupId);
const pool = getPool(); const pool = getPool();
await pool.query( 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) { export async function joinGroup(userId: number, inviteCode: string) {
await enforceUserWriteRateLimit({ userId, scope: "groups:join" });
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( const { rows } = await pool.query(
"select id, name from groups where invite_code=$1", "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]; const group = rows[0];
if (!group) if (!group)
apiError("INVALID_INVITE", { inviteCode }); apiError("INVALID_INVITE", { inviteCodeLast4: last4(inviteCode) });
const existing = await pool.query( const existing = await pool.query(
"select 1 from group_members where group_id=$1 and user_id=$2", "select 1 from group_members where group_id=$1 and user_id=$2",
[group.id, userId] [group.id, userId]
); );
if (existing.rowCount) if (existing.rows.length)
return { id: group.id, name: group.name } as { id: number; name: string }; return { id: group.id, name: group.name } as { id: number; name: string };
const settings = await pool.query( const settings = await pool.query(

View File

@ -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<void> | 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)
});
}

View File

@ -18,6 +18,6 @@ export async function updateRecurringEntry(input: Parameters<typeof updateEntry>
return updateEntry({ ...input, isRecurring: true }); 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); return deleteEntry(input);
} }

View File

@ -3,6 +3,7 @@ if (process.env.NODE_ENV !== "test")
import getPool from "@/lib/server/db"; import getPool from "@/lib/server/db";
import { apiError } from "@/lib/server/errors"; import { apiError } from "@/lib/server/errors";
import { getGroupRole, isAdminRole } from "@/lib/server/group-access"; import { getGroupRole, isAdminRole } from "@/lib/server/group-access";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
type TagRow = { id: number; name: string }; type TagRow = { id: number; name: string };
type TagListRow = { 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 }) { 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); const allowed = await canManageTags(input.userId, input.groupId);
if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId }); if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId });
const pool = getPool(); const pool = getPool();
@ -67,6 +69,7 @@ export async function ensureTagsForGroup(input: { userId: number; groupId: numbe
const missing = tags.filter(tag => !existing.has(tag)); const missing = tags.filter(tag => !existing.has(tag));
if (missing.length) { if (missing.length) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "tags:create" });
const allowed = await canManageTags(input.userId, input.groupId); const allowed = await canManageTags(input.userId, input.groupId);
if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId, missing }); if (!allowed) apiError("FORBIDDEN", { userId: input.userId, groupId: input.groupId, missing });

View File

@ -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;

59
docker/nginx/fiddy.conf Normal file
View File

@ -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;
}
}

View File

@ -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;

View File

@ -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:

View File

@ -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

View File

@ -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

68
docs/05_REFACTOR_2.md Normal file
View File

@ -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.

View File

@ -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 Lets 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/<file>.dump <target_database_url>`
- 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.

View File

@ -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);

View File

@ -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}"

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <backup.dump> <target_database_url>"
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."