diff --git a/.env.localdev b/.env.localdev index 669409c..3aa99a4 100644 --- a/.env.localdev +++ b/.env.localdev @@ -22,11 +22,11 @@ COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc= # Leave unset in local dev so host-only cookie is used. # COOKIE_DOMAIN= AUTH_DISABLED=false -ALLOW_INSECURE_AUTH_FOR_DEV=false +ALLOW_INSECURE_AUTH_FOR_DEV=true SEED_DEFAULT_BUDGET=false BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ EMAIL_VERIFY_DEV_CODE_EXPOSE=true UNDER_CONSTRUCTION_ENABLED=false -NODE_ENV=development \ No newline at end of file +NODE_ENV=development \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3e605e6..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "css.lint.unknownAtRules": "ignore", - "scss.lint.unknownAtRules": "ignore", - "less.lint.unknownAtRules": "ignore", - "cSpell.words": [ - "skymoney" - ] -} \ No newline at end of file diff --git a/SECURITY_FORGOT_PASSWORD.md b/SECURITY_FORGOT_PASSWORD.md deleted file mode 100644 index 1c772f3..0000000 --- a/SECURITY_FORGOT_PASSWORD.md +++ /dev/null @@ -1,46 +0,0 @@ -# Security Forgot Password Controls - -## Implemented Controls - -- Public entry points: - - `POST /auth/forgot-password/request` - - `POST /auth/forgot-password/confirm` -- Enumeration resistance: - - Request endpoint always returns `200` with a generic success message. - - No account existence signal for unknown/unverified emails. -- Verified-account gate: - - Reset tokens are issued only when `emailVerified=true`. -- Token security: - - Reset links contain `uid` and raw `token` in query params. - - Server stores only `SHA-256(token)`. - - Token type is `password_reset`. - - Token must match `uid`, be unused, and be unexpired. - - Token is consumed once (`usedAt`) and cannot be reused. -- Session invalidation: - - Added `User.passwordChangedAt`. - - JWT auth middleware rejects tokens with `iat <= passwordChangedAt`. - - Reset and authenticated password-change both set `passwordChangedAt`. -- Abuse controls: - - Request endpoint: per-IP + email-fingerprint route keying. - - Confirm endpoint: per-IP + uid-fingerprint route keying. - - Endpoint-specific rate limits via env config. -- Logging hygiene: - - Structured security events for request/email/confirm outcomes. - - No plaintext password or raw token in logs. -- Misconfiguration resilience: - - Email send failures do not leak through API response shape. - - Generic response is preserved if SMTP is unavailable. - -## Environment Settings - -- `PASSWORD_RESET_TTL_MINUTES` (default `30`) -- `PASSWORD_RESET_RATE_LIMIT_PER_MINUTE` (default `5`) -- `PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE` (default `10`) - -## Operational Verification - -1. Verify `/auth/forgot-password/request` always returns the same JSON for unknown, unverified, and verified addresses. -2. Verify only verified users get `EmailToken(type=password_reset)` rows. -3. Verify `confirm` succeeds once and fails on replay. -4. Verify pre-reset session cookies fail on protected routes after successful reset. -5. Verify security logs contain `auth.password_reset.*` events and no raw token values. diff --git a/TIMEZONE_ROLLOVER_EXPLAINED.md b/TIMEZONE_ROLLOVER_EXPLAINED.md deleted file mode 100644 index f57cf6e..0000000 --- a/TIMEZONE_ROLLOVER_EXPLAINED.md +++ /dev/null @@ -1,121 +0,0 @@ -# Timezone-Aware Job Scheduling - -## How It Works - -### Cron Schedule (Every 15 Minutes) -``` -*/15 * * * * → Runs at: 00:00, 00:15, 00:30, 00:45, 01:00, 01:15, ... -``` - -### Each Run: -1. **Query** all candidate plans (across ALL users, all timezones) -2. **For each plan**, check: "Is it past target hour in THIS user's timezone?" -3. **If yes** → process the plan -4. **If no** → skip until next run (15 min later) - -## Real World Example: Rollover at 6 AM Local Time - -### Timeline Across Timezones (Dec 17, 2025) - -| UTC Time | Los Angeles (UTC-8) | New York (UTC-5) | London (UTC+0) | Tokyo (UTC+9) | Action | -|----------|---------------------|------------------|----------------|---------------|--------| -| 21:00 Dec 16 | 1:00 PM Dec 16 | 4:00 PM Dec 16 | 9:00 PM Dec 16 | **6:00 AM Dec 17** | ✅ Process Tokyo users | -| 00:00 Dec 17 | 4:00 PM Dec 16 | 7:00 PM Dec 16 | 12:00 AM Dec 17 | 9:00 AM Dec 17 | (Tokyo already done) | -| 06:00 Dec 17 | 10:00 PM Dec 16 | 1:00 AM Dec 17 | **6:00 AM Dec 17** | 3:00 PM Dec 17 | ✅ Process London users | -| 11:00 Dec 17 | 3:00 AM Dec 17 | **6:00 AM Dec 17** | 11:00 AM Dec 17 | 8:00 PM Dec 17 | ✅ Process NYC users | -| 14:00 Dec 17 | **6:00 AM Dec 17** | 9:00 AM Dec 17 | 2:00 PM Dec 17 | 11:00 PM Dec 17 | ✅ Process LA users | - -### Processing Window -- **With 15-min cron**: Users processed within **0-15 minutes** after their local 6 AM -- **With hourly cron**: Users processed within **0-60 minutes** after their local 6 AM -- **With 5-min cron**: Users processed within **0-5 minutes** after their local 6 AM - -## Why This Approach? - -### ✅ Advantages -1. **No per-user scheduling** needed - single cron handles all users -2. **Automatic timezone handling** - works for any timezone without config -3. **Scalable** - adding users doesn't increase job complexity -4. **Self-correcting** - if a job misses a run, next run catches it - -### ⚠️ Considerations -1. **Small delay** - Users processed within 15 min (not exactly at 6:00 AM) -2. **Query overhead** - Queries all candidate plans every 15 min -3. **Database filtering** - Good indexes on `dueOn` and `nextPaymentDate` are important - -### 🔄 Alternative Approach (Not Implemented) -Store each user's next run time as UTC timestamp: -```sql -nextRolloverAt = '2025-12-17T21:00:00Z' -- for Tokyo user's 6 AM -``` -Then query: `WHERE nextRolloverAt <= NOW()` - -**Trade-offs:** -- ✅ Exact timing - no delay -- ✅ More efficient query - index on single timestamp column -- ❌ More complex - need to update nextRolloverAt after each run -- ❌ DST complications - need to recalculate when timezone rules change - -## Configuration - -### Environment Variables -```bash -# Rollover: default = every 15 minutes -ROLLOVER_SCHEDULE_CRON="*/15 * * * *" - -# Auto-payment: default = every 15 minutes -AUTO_PAYMENT_SCHEDULE_CRON="*/15 * * * *" - -# For high-precision (every 5 minutes): -ROLLOVER_SCHEDULE_CRON="*/5 * * * *" - -# For lower load (hourly): -ROLLOVER_SCHEDULE_CRON="0 * * * *" -``` - -### Cron Format -``` -* * * * * -│ │ │ │ │ -│ │ │ │ └─ Day of week (0-7, both 0 and 7 = Sunday) -│ │ │ └─── Month (1-12) -│ │ └───── Day of month (1-31) -│ └─────── Hour (0-23) -└───────── Minute (0-59) - -Examples: -*/15 * * * * → Every 15 minutes -0 * * * * → Every hour at :00 -0 6 * * * → Once daily at 6 AM UTC -*/5 * * * * → Every 5 minutes -``` - -## Testing - -### Test Specific Timezone -```bash -# 1. Change user timezone -docker compose exec -T postgres psql -U app -d skymoney -c \ - "UPDATE \"User\" SET timezone = 'Asia/Tokyo' WHERE id = 'user-id';" - -# 2. Run test script -npx tsx src/scripts/test-timezone-jobs.ts user-id -``` - -### Simulate Specific UTC Time -```typescript -import { rolloverFixedPlans } from "./src/jobs/rollover.js"; - -// Simulate running at 21:00 UTC (= 6 AM Tokyo) -await rolloverFixedPlans(prisma, "2025-12-17T21:00:00Z", { dryRun: true }); -``` - -### Test Different Timezones -```bash -# Tokyo (UTC+9) - 6 AM = 21:00 UTC previous day -npx tsx -e "import { rolloverFixedPlans } from './src/jobs/rollover.js'; ..." - -# Los Angeles (UTC-8) - 6 AM = 14:00 UTC same day -# London (UTC+0) - 6 AM = 06:00 UTC same day -# New York (UTC-5) - 6 AM = 11:00 UTC same day -``` diff --git a/api/GET b/api/GET deleted file mode 100644 index e69de29..0000000 diff --git a/api/allocator b/api/allocator deleted file mode 100644 index e69de29..0000000 diff --git a/api/check-allocations.cjs b/api/check-allocations.cjs deleted file mode 100644 index a674173..0000000 --- a/api/check-allocations.cjs +++ /dev/null @@ -1,34 +0,0 @@ -const {PrismaClient} = require('@prisma/client'); - -async function checkAllocations() { - const p = new PrismaClient(); - - try { - const user = await p.user.findUnique({ - where: { email: 'test@skymoney.com' } - }); - - const income = await p.incomeEvent.findFirst({ - where: { userId: user.id }, - orderBy: { postedAt: 'desc' }, - include: { allocations: true } - }); - - console.log('\n💵 LATEST INCOME:', Number(income.amountCents)/100); - console.log('\n📊 ALLOCATIONS:'); - - for (const a of income.allocations) { - if (a.kind === 'fixed') { - const plan = await p.fixedPlan.findUnique({ where: { id: a.toId } }); - console.log(' Fixed -', plan.name + ':', Number(a.amountCents)/100); - } else if (a.kind === 'variable') { - const cat = await p.variableCategory.findUnique({ where: { id: a.toId } }); - console.log(' Variable -', cat.name + ':', Number(a.amountCents)/100); - } - } - } finally { - await p.$disconnect(); - } -} - -checkAllocations(); diff --git a/api/check-overdue.cjs b/api/check-overdue.cjs deleted file mode 100644 index b76b177..0000000 --- a/api/check-overdue.cjs +++ /dev/null @@ -1,49 +0,0 @@ -// Script to check overdue status of test user -const { PrismaClient } = require('@prisma/client'); - -async function main() { - const prisma = new PrismaClient(); - - try { - const user = await prisma.user.findUnique({ - where: { email: 'test@skymoney.com' } - }); - - if (!user) { - console.log('❌ Test user not found. Run create-test-user.cjs first.'); - return; - } - - console.log('✅ Found test user:', user.email); - - const plans = await prisma.fixedPlan.findMany({ - where: { userId: user.id }, - select: { - id: true, - name: true, - totalCents: true, - fundedCents: true, - isOverdue: true, - overdueAmount: true, - overdueSince: true, - }, - }); - - console.log('\n📋 Fixed Plans:'); - for (const plan of plans) { - console.log(`\n ${plan.name}:`); - console.log(` Total: $${Number(plan.totalCents) / 100}`); - console.log(` Funded: $${Number(plan.fundedCents) / 100}`); - console.log(` Overdue: ${plan.isOverdue ? 'YES' : 'NO'}`); - if (plan.isOverdue) { - console.log(` Overdue Amount: $${plan.overdueAmount / 100}`); - console.log(` Overdue Since: ${plan.overdueSince}`); - } - } - - } finally { - await prisma.$disconnect(); - } -} - -main(); diff --git a/api/clients/ts/sdk.ts b/api/clients/ts/sdk.ts deleted file mode 100644 index d192413..0000000 --- a/api/clients/ts/sdk.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first. - Usage: - import { SkyMoney } from "./sdk"; - const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL }); - const dash = await api.dashboard.get(); -*/ - -export type TransactionKind = "variable_spend" | "fixed_payment"; - -export interface OkResponse { ok: true } -export interface ErrorResponse { - ok: false; code: string; message: string; requestId: string; -} - -export interface VariableCategory { - id: string; - userId?: string; - name: string; - percent: number; // 0..100 - isSavings: boolean; - priority: number; - balanceCents?: number; -} - -export interface FixedPlan { - id: string; - userId?: string; - name: string; - totalCents?: number; - fundedCents?: number; - priority: number; - dueOn: string; // ISO - cycleStart?: string;// ISO -} - -export interface Transaction { - id: string; - userId?: string; - kind: TransactionKind; - amountCents: number; - occurredAt: string; // ISO - categoryId?: string | null; - planId?: string | null; -} - -export interface TransactionList { - items: Transaction[]; - page: number; - limit: number; - total: number; -} - -export interface DashboardResponse { - totals: { - incomeCents: number; - variableBalanceCents: number; - fixedRemainingCents: number; - }; - percentTotal: number; - variableCategories: VariableCategory[]; - fixedPlans: FixedPlan[]; - recentTransactions: Transaction[]; -} - -export interface IncomeRequest { amountCents: number; } - -export interface AllocationItem { - id: string; name: string; amountCents: number; -} - -export interface IncomePreviewResponse { - fixed: AllocationItem[]; - variable: AllocationItem[]; - unallocatedCents: number; -} - -// allocateIncome returns a richer object; tests expect these fields: -export interface IncomeAllocationResponse { - fixedAllocations?: AllocationItem[]; - variableAllocations?: AllocationItem[]; - remainingUnallocatedCents?: number; - // allow any extra fields without type errors: - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [k: string]: any; -} - -export type FetchLike = typeof fetch; - -export type SDKOptions = { - baseUrl?: string; - fetch?: FetchLike; - requestIdFactory?: () => string; // to set x-request-id if desired -}; - -function makeQuery(params: Record): string { - const sp = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v === undefined || v === null || v === "") continue; - sp.set(k, String(v)); - } - const s = sp.toString(); - return s ? `?${s}` : ""; -} - -export class SkyMoney { - readonly baseUrl: string; - private readonly f: FetchLike; - private readonly reqId?: () => string; - - constructor(opts: SDKOptions = {}) { - this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, ""); - this.f = opts.fetch || fetch; - this.reqId = opts.requestIdFactory; - } - - private async request( - method: "GET" | "POST" | "PATCH" | "DELETE", - path: string, - body?: unknown, - query?: Record, - headers?: Record - ): Promise { - const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`; - const h: Record = { ...(headers || {}) }; - if (this.reqId) h["x-request-id"] = this.reqId(); - const hasBody = body !== undefined && body !== null; - - const res = await this.f(url, { - method, - credentials: "include", - headers: { - ...(hasBody ? { "content-type": "application/json" } : {}), - ...h, - }, - body: hasBody ? JSON.stringify(body) : undefined, - }); - - // Attempt to parse JSON; fall back to text - const text = await res.text(); - const data = text ? safeJson(text) : undefined; - - if (!res.ok) { - const err = new Error((data as any)?.message || `HTTP ${res.status}`); - (err as any).status = res.status; - (err as any).body = data ?? text; - throw err; - } - return data as T; - } - - // ---- Health - health = { - get: () => this.request<{ ok: true }>("GET", "/health"), - db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"), - }; - - // ---- Dashboard - dashboard = { - get: () => this.request("GET", "/dashboard"), - }; - - // ---- Income - income = { - preview: (amountCents: number) => - this.request("POST", "/income/preview", { amountCents }), - create: (amountCents: number) => - this.request("POST", "/income", { amountCents }), - }; - - // ---- Transactions - transactions = { - list: (args: { - from?: string; // YYYY-MM-DD - to?: string; // YYYY-MM-DD - kind?: TransactionKind; - q?: string; - page?: number; - limit?: number; - }) => - this.request("GET", "/transactions", undefined, args), - create: (payload: { - kind: TransactionKind; - amountCents: number; - occurredAtISO: string; - categoryId?: string; - planId?: string; - }) => this.request("POST", "/transactions", payload), - }; - - // ---- Variable Categories - variableCategories = { - create: (payload: { - name: string; - percent: number; - isSavings: boolean; - priority: number; - }) => this.request("POST", "/variable-categories", payload), - - update: (id: string, patch: Partial<{ - name: string; - percent: number; - isSavings: boolean; - priority: number; - }>) => this.request("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch), - - delete: (id: string) => - this.request("DELETE", `/variable-categories/${encodeURIComponent(id)}`), - }; - - // ---- Fixed Plans - fixedPlans = { - create: (payload: { - name: string; - totalCents: number; - fundedCents?: number; - priority: number; - dueOn: string; // ISO - cycleStart?: string; // ISO - }) => this.request("POST", "/fixed-plans", payload), - - update: (id: string, patch: Partial<{ - name: string; - totalCents: number; - fundedCents: number; - priority: number; - dueOn: string; - cycleStart: string; - }>) => this.request("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch), - - delete: (id: string) => - this.request("DELETE", `/fixed-plans/${encodeURIComponent(id)}`), - }; -} - -// ---------- helpers ---------- -function safeJson(s: string) { - try { return JSON.parse(s) } catch { return s } -} diff --git a/api/create-multi-overdue-test.cjs b/api/create-multi-overdue-test.cjs deleted file mode 100644 index 6869931..0000000 --- a/api/create-multi-overdue-test.cjs +++ /dev/null @@ -1,135 +0,0 @@ -const argon2 = require('argon2'); -const { PrismaClient } = require('@prisma/client'); - -async function createTestUser() { - const prisma = new PrismaClient({ - datasourceUrl: 'postgres://app:app@localhost:5432/skymoney' - }); - - try { - // Delete existing test user if exists - await prisma.user.deleteMany({ - where: { email: 'test@skymoney.com' } - }); - console.log('✓ Cleaned up old test user'); - - // Create user - const hash = await argon2.hash('password123'); - const user = await prisma.user.create({ - data: { - email: 'test@skymoney.com', - passwordHash: hash, - displayName: 'Test User', - timezone: 'America/New_York' - } - }); - console.log('✓ Created user:', user.id); - - // Create categories (must total 100%) - await prisma.variableCategory.create({ - data: { - userId: user.id, - name: 'Groceries', - percent: 50, - balanceCents: 150000n // $1500 - } - }); - await prisma.variableCategory.create({ - data: { - userId: user.id, - name: 'Other', - percent: 50, - balanceCents: 150000n // $1500 - } - }); - console.log('✓ Created categories (100% total)'); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Create 3 overdue bills with different overdue dates (oldest first priority) - - // 1. RENT - Overdue 5 days ago (OLDEST = HIGHEST PRIORITY) - const rentOverdue = new Date(today); - rentOverdue.setDate(rentOverdue.getDate() - 5); - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Rent', - totalCents: 150000n, // $1500 total - fundedCents: 100000n, // $1000 funded - currentFundedCents: 100000n, - dueOn: rentOverdue, - cycleStart: rentOverdue, - frequency: 'monthly', - needsFundingThisPeriod: true, - isOverdue: true, - overdueAmount: 50000n, // $500 overdue - overdueSince: rentOverdue - } - }); - console.log('✓ Rent: $1500 total, $500 overdue (5 days ago - OLDEST)'); - - // 2. UTILITIES - Overdue 3 days ago (SECOND PRIORITY) - const utilOverdue = new Date(today); - utilOverdue.setDate(utilOverdue.getDate() - 3); - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Utilities', - totalCents: 20000n, // $200 total - fundedCents: 10000n, // $100 funded - currentFundedCents: 10000n, - dueOn: utilOverdue, - cycleStart: utilOverdue, - frequency: 'monthly', - needsFundingThisPeriod: true, - isOverdue: true, - overdueAmount: 10000n, // $100 overdue - overdueSince: utilOverdue - } - }); - console.log('✓ Utilities: $200 total, $100 overdue (3 days ago)'); - - // 3. PHONE - Overdue 1 day ago (NEWEST = LOWEST PRIORITY) - const phoneOverdue = new Date(today); - phoneOverdue.setDate(phoneOverdue.getDate() - 1); - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Phone', - totalCents: 10000n, // $100 total - fundedCents: 5000n, // $50 funded - currentFundedCents: 5000n, - dueOn: phoneOverdue, - cycleStart: phoneOverdue, - frequency: 'monthly', - needsFundingThisPeriod: true, - isOverdue: true, - overdueAmount: 5000n, // $50 overdue - overdueSince: phoneOverdue - } - }); - console.log('✓ Phone: $100 total, $50 overdue (1 day ago - NEWEST)'); - - console.log('\n✅ Multi-overdue test user ready!'); - console.log(' Email: test@skymoney.com'); - console.log(' Password: password123'); - console.log('\n OVERDUE BILLS (priority order):'); - console.log(' 1. Rent: $500 (5 days overdue)'); - console.log(' 2. Utilities: $100 (3 days overdue)'); - console.log(' 3. Phone: $50 (1 day overdue)'); - console.log(' TOTAL OVERDUE: $650'); - console.log('\n Test scenarios:'); - console.log(' - Post $500 income → Should pay Rent only'); - console.log(' - Post $600 income → Should pay Rent ($500) + Utilities ($100)'); - console.log(' - Post $700 income → Should pay all 3 overdue bills'); - - } catch (error) { - console.error('❌ Error:', error.message); - } finally { - await prisma.$disconnect(); - } -} - -createTestUser(); diff --git a/api/create-multi-overdue-user.cjs b/api/create-multi-overdue-user.cjs deleted file mode 100644 index 693c273..0000000 --- a/api/create-multi-overdue-user.cjs +++ /dev/null @@ -1,133 +0,0 @@ -// Create test user with MULTIPLE overdue bills -const argon2 = require('argon2'); -const { PrismaClient } = require('@prisma/client'); - -async function main() { - const prisma = new PrismaClient({ - datasourceUrl: 'postgres://app:app@localhost:5432/skymoney' - }); - - try { - const email = 'test@skymoney.com'; - const password = 'password123'; - - // Clean up existing user - await prisma.user.deleteMany({ where: { email } }); - console.log('✓ Cleaned up old test user'); - - // Create user - const passwordHash = await argon2.hash(password); - const user = await prisma.user.create({ - data: { - email, - passwordHash, - displayName: 'Test User', - incomeFrequency: 'biweekly', - totalBudgetCents: BigInt(300000), // $3000 - timezone: 'America/New_York', - }, - }); - console.log('✓ Created user:', user.id); - - // Create income source - await prisma.incomeEvent.create({ - data: { - id: '00000000-0000-0000-0000-000000000001', - userId: user.id, - postedAt: new Date(), - amountCents: BigInt(300000), - note: 'Initial budget', - }, - }); - console.log('✓ Created income: $3000'); - - // Create categories - await prisma.variableCategory.createMany({ - data: [ - { userId: user.id, name: 'Groceries', percent: 50, priority: 1, balanceCents: BigInt(150000) }, - { userId: user.id, name: 'Other', percent: 50, priority: 2, balanceCents: BigInt(150000) }, - ], - }); - console.log('✓ Created categories (100% total)'); - - const today = new Date(); - today.setHours(6, 0, 0, 0); // 6am today - - const threeDaysAgo = new Date(today); - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); - - const oneWeekAgo = new Date(today); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); - - // Create THREE overdue bills with different dates - // 1. Rent - $1500, $1000 funded, $500 overdue (oldest - 7 days ago) - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Rent', - cycleStart: oneWeekAgo, - dueOn: today, - totalCents: BigInt(150000), // $1500 - fundedCents: BigInt(100000), // $1000 funded - currentFundedCents: BigInt(100000), - priority: 1, - isOverdue: true, - overdueAmount: BigInt(50000), // $500 overdue - overdueSince: oneWeekAgo, - }, - }); - - // 2. Utilities - $200, $100 funded, $100 overdue (3 days ago) - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Utilities', - cycleStart: threeDaysAgo, - dueOn: today, - totalCents: BigInt(20000), // $200 - fundedCents: BigInt(10000), // $100 funded - currentFundedCents: BigInt(10000), - priority: 2, - isOverdue: true, - overdueAmount: BigInt(10000), // $100 overdue - overdueSince: threeDaysAgo, - }, - }); - - // 3. Phone - $100, $50 funded, $50 overdue (today) - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Phone', - cycleStart: today, - dueOn: today, - totalCents: BigInt(10000), // $100 - fundedCents: BigInt(5000), // $50 funded - currentFundedCents: BigInt(5000), - priority: 3, - isOverdue: true, - overdueAmount: BigInt(5000), // $50 overdue - overdueSince: today, - }, - }); - - console.log('✓ Created 3 overdue plans:'); - console.log(' - Rent: $1500 total, $1000 funded, $500 overdue (7 days ago)'); - console.log(' - Utilities: $200 total, $100 funded, $100 overdue (3 days ago)'); - console.log(' - Phone: $100 total, $50 funded, $50 overdue (today)'); - console.log('\n✅ Test user ready!'); - console.log(' Email: test@skymoney.com'); - console.log(' Password: password123'); - console.log(' Total overdue: $650'); - console.log('\n💡 Post $1000 income to see priority order:'); - console.log(' 1st: Rent $500 (oldest)'); - console.log(' 2nd: Utilities $100'); - console.log(' 3rd: Phone $50'); - console.log(' Remaining $350 → normal allocation'); - - } finally { - await prisma.$disconnect(); - } -} - -main(); diff --git a/api/create-test-user.cjs b/api/create-test-user.cjs deleted file mode 100644 index 48ad302..0000000 --- a/api/create-test-user.cjs +++ /dev/null @@ -1,91 +0,0 @@ -const argon2 = require('argon2'); -const { PrismaClient } = require('@prisma/client'); - -async function createTestUser() { - const prisma = new PrismaClient({ - datasourceUrl: 'postgres://app:app@localhost:5432/skymoney' - }); - - try { - // Delete existing test user if exists - await prisma.user.deleteMany({ - where: { email: 'test@skymoney.com' } - }); - console.log('✓ Cleaned up old test user'); - - // Create user - const hash = await argon2.hash('password123'); - const user = await prisma.user.create({ - data: { - email: 'test@skymoney.com', - passwordHash: hash, - displayName: 'Test User', - timezone: 'America/New_York' - } - }); - console.log('✓ Created user:', user.id); - - // Create income - await prisma.incomeEvent.create({ - data: { - userId: user.id, - amountCents: 300000n, // $3000 - postedAt: new Date(), - isScheduledIncome: true - } - }); - console.log('✓ Created income: $3000'); - - // Create categories (must total 100%) - await prisma.variableCategory.create({ - data: { - userId: user.id, - name: 'Groceries', - percent: 50, - balanceCents: 150000n // $1500 - } - }); - await prisma.variableCategory.create({ - data: { - userId: user.id, - name: 'Other', - percent: 50, - balanceCents: 0n - } - }); - console.log('✓ Created categories (100% total)'); - - // Create rent bill due today - PARTIALLY FUNDED & OVERDUE - const today = new Date(); - today.setHours(0, 0, 0, 0); - - await prisma.fixedPlan.create({ - data: { - userId: user.id, - name: 'Rent', - totalCents: 150000n, // $1500 total - fundedCents: 100000n, // $1000 funded (partial) - currentFundedCents: 100000n, // $1000 available - dueOn: today, - cycleStart: today, - frequency: 'monthly', - needsFundingThisPeriod: true, - isOverdue: true, // Marked overdue - overdueAmount: 50000n, // $500 outstanding - overdueSince: new Date() - } - }); - console.log('✓ Created Rent plan: $1500 total, $1000 funded, $500 overdue'); - console.log('\n✅ Test user ready!'); - console.log(' Email: test@skymoney.com'); - console.log(' Password: password123'); - console.log(' Rent: $1500 due today (partially funded, overdue)'); - - } catch (error) { - console.error('❌ Error:', error.message); - } finally { - await prisma.$disconnect(); - } -} - -createTestUser(); diff --git a/api/distributes b/api/distributes deleted file mode 100644 index e69de29..0000000 diff --git a/api/handles b/api/handles deleted file mode 100644 index e69de29..0000000 diff --git a/api/openapi.yaml b/api/openapi.yaml deleted file mode 100644 index 55d3812..0000000 --- a/api/openapi.yaml +++ /dev/null @@ -1,552 +0,0 @@ -openapi: 3.0.3 -info: - title: SkyMoney API - version: 0.1.0 - description: | - Fastify backend for budgeting/allocations. - Authentication uses secure httpOnly session cookies (Fastify JWT). During tests - or local development you can set `AUTH_DISABLED=1` to use the legacy `x-user-id` - header for impersonation, but production relies on the session cookie. - -servers: - - url: http://localhost:8080 - -tags: - - name: Health - - name: Dashboard - - name: Income - - name: Transactions - - name: VariableCategories - - name: FixedPlans - -paths: - /health: - get: - tags: [Health] - summary: Liveness check - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/HealthOk' } - - /health/db: - get: - tags: [Health] - summary: DB health + latency - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - responses: - '200': - description: DB OK - content: - application/json: - schema: { $ref: '#/components/schemas/DbHealth' } - - /dashboard: - get: - tags: [Dashboard] - summary: Aggregated dashboard data - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - responses: - '200': - description: Dashboard payload - content: - application/json: - schema: { $ref: '#/components/schemas/DashboardResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '500': - $ref: '#/components/responses/InternalError' - - /income: - post: - tags: [Income] - summary: Create income event and allocate funds - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/IncomeRequest' } - responses: - '200': - description: Allocation result - content: - application/json: - schema: { $ref: '#/components/schemas/IncomeAllocationResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '500': - $ref: '#/components/responses/InternalError' - - /income/preview: - post: - tags: [Income] - summary: Preview allocation of a hypothetical income amount - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/IncomeRequest' } - responses: - '200': - description: Preview - content: - application/json: - schema: { $ref: '#/components/schemas/IncomePreviewResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '500': - $ref: '#/components/responses/InternalError' - - /transactions: - get: - tags: [Transactions] - summary: List transactions with filters and pagination - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - - in: query - name: from - schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' } - description: Inclusive start date (YYYY-MM-DD) - - in: query - name: to - schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' } - description: Inclusive end date (YYYY-MM-DD) - - in: query - name: kind - schema: { $ref: '#/components/schemas/TransactionKind' } - - in: query - name: q - schema: { type: string } - description: Simple search (currently numeric amount match) - - in: query - name: page - schema: { type: integer, minimum: 1, default: 1 } - - in: query - name: limit - schema: { type: integer, minimum: 1, maximum: 100, default: 20 } - responses: - '200': - description: List - content: - application/json: - schema: { $ref: '#/components/schemas/TransactionList' } - '400': - $ref: '#/components/responses/BadRequest' - '500': - $ref: '#/components/responses/InternalError' - post: - tags: [Transactions] - summary: Create a transaction - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/TransactionCreate' } - responses: - '200': - description: Created - content: - application/json: - schema: { $ref: '#/components/schemas/Transaction' } - '400': - $ref: '#/components/responses/BadRequest' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - - /variable-categories: - post: - tags: [VariableCategories] - summary: Create a variable category (sum of percents must be 100) - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/VariableCategoryCreate' } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/OkResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '500': - $ref: '#/components/responses/InternalError' - - /variable-categories/{id}: - patch: - tags: [VariableCategories] - summary: Update a variable category (sum of percents must be 100) - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - - in: path - name: id - required: true - schema: { type: string } - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/VariableCategoryPatch' } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/OkResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - delete: - tags: [VariableCategories] - summary: Delete a variable category (sum of percents must remain 100) - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - - in: path - name: id - required: true - schema: { type: string } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/OkResponse' } - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - - /fixed-plans: - post: - tags: [FixedPlans] - summary: Create a fixed plan - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/FixedPlanCreate' } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/OkResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '500': - $ref: '#/components/responses/InternalError' - - /fixed-plans/{id}: - patch: - tags: [FixedPlans] - summary: Update a fixed plan (fundedCents cannot exceed totalCents) - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - - in: path - name: id - required: true - schema: { type: string } - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/FixedPlanPatch' } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/OkResponse' } - '400': - $ref: '#/components/responses/BadRequest' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - delete: - tags: [FixedPlans] - summary: Delete a fixed plan - parameters: - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RequestId' - - in: path - name: id - required: true - schema: { type: string } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/OkResponse' } - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - -components: - parameters: - UserId: - in: header - name: x-user-id - required: false - description: | - Dev/test-only tenant selector when AUTH_DISABLED=1. Production requests rely - on the session cookie instead and should omit this header. - schema: { type: string } - description: Override the stubbed user id for the request. - RequestId: - in: header - name: x-request-id - required: false - schema: { type: string, maxLength: 64 } - description: Custom request id (echoed back by server). - - responses: - BadRequest: - description: Validation or guard failed - content: - application/json: - schema: { $ref: '#/components/schemas/ErrorResponse' } - NotFound: - description: Resource not found - content: - application/json: - schema: { $ref: '#/components/schemas/ErrorResponse' } - InternalError: - description: Unexpected server error - content: - application/json: - schema: { $ref: '#/components/schemas/ErrorResponse' } - - schemas: - HealthOk: - type: object - properties: - ok: { type: boolean, const: true } - required: [ok] - - DbHealth: - type: object - properties: - ok: { type: boolean, const: true } - nowISO: { type: string, format: date-time } - latencyMs: { type: integer, minimum: 0 } - required: [ok, nowISO, latencyMs] - - OkResponse: - type: object - properties: - ok: { type: boolean, const: true } - required: [ok] - - ErrorResponse: - type: object - properties: - ok: { type: boolean, const: false } - code: { type: string } - message: { type: string } - requestId: { type: string } - required: [ok, code, message, requestId] - - VariableCategory: - type: object - properties: - id: { type: string } - userId: { type: string } - name: { type: string } - percent: { type: integer, minimum: 0, maximum: 100 } - isSavings: { type: boolean } - priority: { type: integer, minimum: 0 } - balanceCents: - type: integer - description: Current balance; may be omitted or 0 when not loaded. - required: [id, userId, name, percent, isSavings, priority] - - FixedPlan: - type: object - properties: - id: { type: string } - userId: { type: string } - name: { type: string } - totalCents: { type: integer, minimum: 0 } - fundedCents: { type: integer, minimum: 0 } - priority: { type: integer, minimum: 0 } - dueOn: { type: string, format: date-time } - cycleStart: { type: string, format: date-time } - required: [id, userId, name, priority, dueOn] - - TransactionKind: - type: string - enum: [variable_spend, fixed_payment] - - Transaction: - type: object - properties: - id: { type: string } - userId: { type: string } - kind: { $ref: '#/components/schemas/TransactionKind' } - amountCents: { type: integer, minimum: 0 } - occurredAt: { type: string, format: date-time } - categoryId: { type: string, nullable: true } - planId: { type: string, nullable: true } - required: [id, userId, kind, amountCents, occurredAt] - - TransactionList: - type: object - properties: - items: - type: array - items: { $ref: '#/components/schemas/Transaction' } - page: { type: integer, minimum: 1 } - limit: { type: integer, minimum: 1, maximum: 100 } - total: { type: integer, minimum: 0 } - required: [items, page, limit, total] - - TransactionCreate: - type: object - properties: - kind: { $ref: '#/components/schemas/TransactionKind' } - amountCents: { type: integer, minimum: 1 } - occurredAtISO: { type: string, format: date-time } - categoryId: { type: string, nullable: true } - planId: { type: string, nullable: true } - required: [kind, amountCents, occurredAtISO] - - DashboardResponse: - type: object - properties: - totals: - type: object - properties: - incomeCents: { type: integer, minimum: 0 } - variableBalanceCents: { type: integer, minimum: 0 } - fixedRemainingCents: { type: integer, minimum: 0 } - required: [incomeCents, variableBalanceCents, fixedRemainingCents] - percentTotal: { type: integer, minimum: 0, maximum: 100 } - variableCategories: - type: array - items: { $ref: '#/components/schemas/VariableCategory' } - fixedPlans: - type: array - items: { $ref: '#/components/schemas/FixedPlan' } - recentTransactions: - type: array - items: { $ref: '#/components/schemas/Transaction' } - required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions] - - IncomeRequest: - type: object - properties: - amountCents: { type: integer, minimum: 0 } - required: [amountCents] - - AllocationItem: - type: object - properties: - id: { type: string } - name: { type: string } - amountCents: { type: integer, minimum: 0 } - required: [id, name, amountCents] - - IncomePreviewResponse: - type: object - properties: - fixed: - type: array - items: { $ref: '#/components/schemas/AllocationItem' } - variable: - type: array - items: { $ref: '#/components/schemas/AllocationItem' } - unallocatedCents: { type: integer, minimum: 0 } - required: [fixed, variable, unallocatedCents] - - IncomeAllocationResponse: - type: object - description: > - Shape returned by allocateIncome. Tests expect: - fixedAllocations, variableAllocations, remainingUnallocatedCents. - Additional fields may be present. - properties: - fixedAllocations: - type: array - items: { $ref: '#/components/schemas/AllocationItem' } - variableAllocations: - type: array - items: { $ref: '#/components/schemas/AllocationItem' } - remainingUnallocatedCents: { type: integer, minimum: 0 } - additionalProperties: true - - VariableCategoryCreate: - type: object - properties: - name: { type: string } - percent: { type: integer, minimum: 0, maximum: 100 } - isSavings: { type: boolean } - priority: { type: integer, minimum: 0 } - required: [name, percent, isSavings, priority] - - VariableCategoryPatch: - type: object - properties: - name: { type: string } - percent: { type: integer, minimum: 0, maximum: 100 } - isSavings: { type: boolean } - priority: { type: integer, minimum: 0 } - additionalProperties: false - - FixedPlanCreate: - type: object - properties: - name: { type: string } - totalCents: { type: integer, minimum: 0 } - fundedCents: { type: integer, minimum: 0 } - priority: { type: integer, minimum: 0 } - dueOn: { type: string, format: date-time } - cycleStart: { type: string, format: date-time } - required: [name, totalCents, priority, dueOn] - - FixedPlanPatch: - type: object - properties: - name: { type: string } - totalCents: { type: integer, minimum: 0 } - fundedCents: { type: integer, minimum: 0 } - priority: { type: integer, minimum: 0 } - dueOn: { type: string, format: date-time } - cycleStart: { type: string, format: date-time } - additionalProperties: false diff --git a/api/paginates b/api/paginates deleted file mode 100644 index e69de29..0000000 diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml deleted file mode 100644 index 0931c23..0000000 --- a/api/pnpm-lock.yaml +++ /dev/null @@ -1,2173 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@fastify/cookie': - specifier: ^11.0.2 - version: 11.0.2 - '@fastify/cors': - specifier: ^10.1.0 - version: 10.1.0 - '@fastify/jwt': - specifier: ^10.0.0 - version: 10.0.0 - '@fastify/rate-limit': - specifier: ^10.3.0 - version: 10.3.0 - '@prisma/client': - specifier: ^5.22.0 - version: 5.22.0(prisma@5.22.0) - argon2: - specifier: ^0.40.1 - version: 0.40.3 - date-fns-tz: - specifier: ^3.2.0 - version: 3.2.0(date-fns@4.1.0) - fastify: - specifier: ^5.6.2 - version: 5.6.2 - node-cron: - specifier: ^4.2.1 - version: 4.2.1 - zod: - specifier: ^3.23.8 - version: 3.25.76 - devDependencies: - '@types/node': - specifier: ^20.19.25 - version: 20.19.25 - '@types/supertest': - specifier: ^6.0.3 - version: 6.0.3 - prisma: - specifier: ^5.22.0 - version: 5.22.0 - supertest: - specifier: ^6.3.4 - version: 6.3.4 - tsx: - specifier: ^4.20.6 - version: 4.20.6 - typescript: - specifier: ^5.6.3 - version: 5.9.3 - vitest: - specifier: ^2.1.3 - version: 2.1.9(@types/node@20.19.25) - -packages: - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@fastify/ajv-compiler@4.0.5': - resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - - '@fastify/cookie@11.0.2': - resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - - '@fastify/cors@10.1.0': - resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} - - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.0.3': - resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - - '@fastify/forwarded@3.0.1': - resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - - '@fastify/jwt@10.0.0': - resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - - '@fastify/rate-limit@10.3.0': - resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@lukeed/ms@2.0.2': - resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} - engines: {node: '>=8'} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - - '@phc/format@1.0.0': - resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} - engines: {node: '>=10'} - - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - - '@prisma/client@5.22.0': - resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} - engines: {node: '>=16.13'} - peerDependencies: - prisma: '*' - peerDependenciesMeta: - prisma: - optional: true - - '@prisma/debug@5.22.0': - resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} - - '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': - resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} - - '@prisma/engines@5.22.0': - resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} - - '@prisma/fetch-engine@5.22.0': - resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} - - '@prisma/get-platform@5.22.0': - resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} - - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} - cpu: [x64] - os: [win32] - - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - - '@types/node@20.19.25': - resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} - - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - argon2@0.40.3: - resolution: {integrity: sha512-FrSmz4VeM91jwFvvjsQv9GYp6o/kARWoYKjbjDB2U5io1H3e5X67PYGclFDeQff6UXIhUd4aHR3mxCdBbMMuQw==} - engines: {node: '>=16.17.0'} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - avvio@9.1.0: - resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - - bn.js@4.12.2: - resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} - engines: {node: '>=18'} - - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} - engines: {node: '>=18'} - - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - - date-fns-tz@3.2.0: - resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} - peerDependencies: - date-fns: ^3.0.0 || ^4.0.0 - - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} - engines: {node: '>=12.0.0'} - - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stringify@6.1.1: - resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} - - fast-jwt@6.0.2: - resolution: {integrity: sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==} - engines: {node: '>=20'} - - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastfall@1.5.1: - resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} - engines: {node: '>=0.10.0'} - - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - - fastify@5.6.2: - resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} - - fastparallel@2.4.1: - resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fastseries@1.7.2: - resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} - - find-my-way@9.3.0: - resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} - engines: {node: '>=20'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ipaddr.js@2.2.0: - resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} - engines: {node: '>= 10'} - - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - - mnemonist@0.40.0: - resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-addon-api@8.5.0: - resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} - engines: {node: ^18 || ^20 || >= 21} - - node-cron@4.2.1: - resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} - engines: {node: '>=6.0.0'} - - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - obliterator@2.0.5: - resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} - - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - pino-abstract-transport@2.0.0: - resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} - - pino-std-serializers@7.0.0: - resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} - - pino@10.1.0: - resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} - hasBin: true - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prisma@5.22.0: - resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} - engines: {node: '>=16.13'} - hasBin: true - - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - steed@1.1.3: - resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} - - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net - - thread-stream@3.1.0: - resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - - toad-cache@3.7.0: - resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} - engines: {node: '>=12'} - - tsx@4.20.6: - resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@fastify/ajv-compiler@4.0.5': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - - '@fastify/cookie@11.0.2': - dependencies: - cookie: 1.0.2 - fastify-plugin: 5.1.0 - - '@fastify/cors@10.1.0': - dependencies: - fastify-plugin: 5.1.0 - mnemonist: 0.40.0 - - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.0.3': - dependencies: - fast-json-stringify: 6.1.1 - - '@fastify/forwarded@3.0.1': {} - - '@fastify/jwt@10.0.0': - dependencies: - '@fastify/error': 4.2.0 - '@lukeed/ms': 2.0.2 - fast-jwt: 6.0.2 - fastify-plugin: 5.1.0 - steed: 1.1.3 - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.2.0 - - '@fastify/rate-limit@10.3.0': - dependencies: - '@lukeed/ms': 2.0.2 - fastify-plugin: 5.1.0 - toad-cache: 3.7.0 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@lukeed/ms@2.0.2': {} - - '@noble/hashes@1.8.0': {} - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@phc/format@1.0.0': {} - - '@pinojs/redact@0.4.0': {} - - '@prisma/client@5.22.0(prisma@5.22.0)': - optionalDependencies: - prisma: 5.22.0 - - '@prisma/debug@5.22.0': {} - - '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} - - '@prisma/engines@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 - '@prisma/fetch-engine': 5.22.0 - '@prisma/get-platform': 5.22.0 - - '@prisma/fetch-engine@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 - '@prisma/get-platform': 5.22.0 - - '@prisma/get-platform@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - - '@rollup/rollup-android-arm-eabi@4.53.3': - optional: true - - '@rollup/rollup-android-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-x64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-arm64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-x64@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-musl@4.53.3': - optional: true - - '@rollup/rollup-openharmony-arm64@4.53.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.53.3': - optional: true - - '@types/cookiejar@2.1.5': {} - - '@types/estree@1.0.8': {} - - '@types/methods@1.1.4': {} - - '@types/node@20.19.25': - dependencies: - undici-types: 6.21.0 - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 20.19.25 - form-data: 4.0.5 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - - '@vitest/expect@2.1.9': - dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 - - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.25))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@20.19.25) - - '@vitest/pretty-format@2.1.9': - dependencies: - tinyrainbow: 1.2.0 - - '@vitest/runner@2.1.9': - dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 - - '@vitest/snapshot@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.21 - pathe: 1.1.2 - - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 - - '@vitest/utils@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 - - abstract-logging@2.0.1: {} - - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - argon2@0.40.3: - dependencies: - '@phc/format': 1.0.0 - node-addon-api: 8.5.0 - node-gyp-build: 4.8.4 - - asap@2.0.6: {} - - asn1.js@5.4.1: - dependencies: - bn.js: 4.12.2 - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - safer-buffer: 2.1.2 - - assertion-error@2.0.1: {} - - asynckit@0.4.0: {} - - atomic-sleep@1.0.0: {} - - avvio@9.1.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.19.1 - - bn.js@4.12.2: {} - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.1: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - component-emitter@1.3.1: {} - - cookie@1.0.2: {} - - cookiejar@2.1.4: {} - - date-fns-tz@3.2.0(date-fns@4.1.0): - dependencies: - date-fns: 4.1.0 - - date-fns@4.1.0: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@5.0.2: {} - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - expect-type@1.2.2: {} - - fast-decode-uri-component@1.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stringify@6.1.1: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - - fast-jwt@6.0.2: - dependencies: - '@lukeed/ms': 2.0.2 - asn1.js: 5.4.1 - ecdsa-sig-formatter: 1.0.11 - mnemonist: 0.40.0 - - fast-querystring@1.1.2: - dependencies: - fast-decode-uri-component: 1.0.1 - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.0: {} - - fastfall@1.5.1: - dependencies: - reusify: 1.1.0 - - fastify-plugin@5.1.0: {} - - fastify@5.6.2: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.1.0 - fast-json-stringify: 6.1.1 - find-my-way: 9.3.0 - light-my-request: 6.6.0 - pino: 10.1.0 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.7.3 - toad-cache: 3.7.0 - - fastparallel@2.4.1: - dependencies: - reusify: 1.1.0 - xtend: 4.0.2 - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fastseries@1.7.2: - dependencies: - reusify: 1.1.0 - xtend: 4.0.2 - - find-my-way@9.3.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.0.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.14.0 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-tsconfig@4.13.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - inherits@2.0.4: {} - - ipaddr.js@2.2.0: {} - - json-schema-ref-resolver@3.0.0: - dependencies: - dequal: 2.0.3 - - json-schema-traverse@1.0.0: {} - - light-my-request@6.6.0: - dependencies: - cookie: 1.0.2 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 - - loupe@3.2.1: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - methods@1.1.2: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@2.6.0: {} - - minimalistic-assert@1.0.1: {} - - mnemonist@0.40.0: - dependencies: - obliterator: 2.0.5 - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - node-addon-api@8.5.0: {} - - node-cron@4.2.1: {} - - node-gyp-build@4.8.4: {} - - object-inspect@1.13.4: {} - - obliterator@2.0.5: {} - - on-exit-leak-free@2.1.2: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - pathe@1.1.2: {} - - pathval@2.0.1: {} - - picocolors@1.1.1: {} - - pino-abstract-transport@2.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.0.0: {} - - pino@10.1.0: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 2.0.0 - pino-std-serializers: 7.0.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.0 - thread-stream: 3.1.0 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prisma@5.22.0: - dependencies: - '@prisma/engines': 5.22.0 - optionalDependencies: - fsevents: 2.3.3 - - process-warning@4.0.1: {} - - process-warning@5.0.0: {} - - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - - quick-format-unescaped@4.0.4: {} - - real-require@0.2.0: {} - - require-from-string@2.0.2: {} - - resolve-pkg-maps@1.0.0: {} - - ret@0.5.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - - rollup@4.53.3: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 - fsevents: 2.3.3 - - safe-buffer@5.2.1: {} - - safe-regex2@5.0.0: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - - safer-buffer@2.1.2: {} - - secure-json-parse@4.1.0: {} - - semver@7.7.3: {} - - set-cookie-parser@2.7.2: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - sonic-boom@4.2.0: - dependencies: - atomic-sleep: 1.0.0 - - source-map-js@1.2.1: {} - - split2@4.2.0: {} - - stackback@0.0.2: {} - - std-env@3.10.0: {} - - steed@1.1.3: - dependencies: - fastfall: 1.5.1 - fastparallel: 2.4.1 - fastq: 1.19.1 - fastseries: 1.7.2 - reusify: 1.1.0 - - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.14.0 - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - - thread-stream@3.1.0: - dependencies: - real-require: 0.2.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - - tinyspy@3.0.2: {} - - toad-cache@3.7.0: {} - - tsx@4.20.6: - dependencies: - esbuild: 0.25.12 - get-tsconfig: 4.13.0 - optionalDependencies: - fsevents: 2.3.3 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - vite-node@2.1.9(@types/node@20.19.25): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@20.19.25) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@20.19.25): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.53.3 - optionalDependencies: - '@types/node': 20.19.25 - fsevents: 2.3.3 - - vitest@2.1.9(@types/node@20.19.25): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.25)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.2.2 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.19.25) - vite-node: 2.1.9(@types/node@20.19.25) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 20.19.25 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wrappy@1.0.2: {} - - xtend@4.0.2: {} - - zod@3.25.76: {} diff --git a/api/src/allocator.ts b/api/src/allocator.ts index 2b1970b..bb3d7f1 100644 --- a/api/src/allocator.ts +++ b/api/src/allocator.ts @@ -297,6 +297,7 @@ async function getInputs( currentFundedCents: true, dueOn: true, priority: true, + fundingMode: true, needsFundingThisPeriod: true, paymentSchedule: true, autoPayEnabled: true, @@ -328,7 +329,8 @@ export function buildPlanStates( userIncomeType?: string, isScheduledIncome?: boolean ): PlanState[] { - const timezone = config.timezone; + const timezone = config.timezone ?? "UTC"; + const firstIncomeDate = config.firstIncomeDate ?? null; const freqDays = frequencyDays[config.incomeFrequency]; // Only handle regular income frequencies @@ -342,7 +344,8 @@ export function buildPlanStates( const remainingCents = Math.max(0, total - funded); const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined; const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true; - const autoFundEnabled = !!p.autoPayEnabled; + const autoFundEnabled = + !p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual"; // Calculate preliminary crisis status to determine if we should override funding restrictions // Use timezone-aware date comparison @@ -357,14 +360,14 @@ export function buildPlanStates( let isPrelimCrisis = false; let dueBeforeNextPayday = false; let daysUntilPayday = 0; - if (isPaymentPlanUser && config.firstIncomeDate) { - const nextPayday = calculateNextPayday(config.firstIncomeDate, config.incomeFrequency, now, timezone); + if (isPaymentPlanUser && firstIncomeDate) { + const nextPayday = calculateNextPayday(firstIncomeDate, config.incomeFrequency, now, timezone); const normalizedNextPayday = getUserMidnight(timezone, nextPayday); daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS)); dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime(); } if (remainingCents >= CRISIS_MINIMUM_CENTS) { - if (isPaymentPlanUser && config.firstIncomeDate) { + if (isPaymentPlanUser && firstIncomeDate) { isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90; } else { isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14; @@ -430,10 +433,10 @@ export function buildPlanStates( // Calculate payment periods more accurately using firstIncomeDate let cyclesLeft: number; - if (config.firstIncomeDate) { + if (firstIncomeDate) { // Count actual pay dates between now and due date based on the recurring pattern // established by firstIncomeDate (pass timezone for correct date handling) - cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, config.firstIncomeDate, config.incomeFrequency, timezone); + cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, firstIncomeDate, config.incomeFrequency, timezone); } else { // Fallback to old calculation if firstIncomeDate not set cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays)); @@ -1377,7 +1380,9 @@ function computeBudgetAllocation( const availableBudget = inputs.availableBefore; const totalPool = availableBudget + newIncome; - const eligiblePlans = inputs.plans.filter((plan) => plan.autoPayEnabled); + const eligiblePlans = inputs.plans.filter( + (plan) => !plan.fundingMode || String(plan.fundingMode).toLowerCase() !== "manual" + ); const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone); // Calculate total remaining needed across all fixed plans @@ -1505,7 +1510,8 @@ function buildBudgetPlanStates( const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn); const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS)); const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined; - const autoFundEnabled = !!p.autoPayEnabled; + const autoFundEnabled = + !p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual"; const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true; // For irregular income, crisis mode triggers earlier (14 days) diff --git a/api/src/jobs/auto-payments.ts b/api/src/jobs/auto-payments.ts index a72ad16..6bb0f75 100644 --- a/api/src/jobs/auto-payments.ts +++ b/api/src/jobs/auto-payments.ts @@ -196,64 +196,64 @@ export function calculateNextPaymentDate( timezone: string ): Date { const next = toZonedTime(currentDate, timezone); - const hours = next.getUTCHours(); - const minutes = next.getUTCMinutes(); - const seconds = next.getUTCSeconds(); - const ms = next.getUTCMilliseconds(); + const hours = next.getHours(); + const minutes = next.getMinutes(); + const seconds = next.getSeconds(); + const ms = next.getMilliseconds(); switch (schedule.frequency) { case "daily": - next.setUTCDate(next.getUTCDate() + 1); + next.setDate(next.getDate() + 1); break; case "weekly": // Move to next occurrence of specified day of week { const targetDay = schedule.dayOfWeek ?? 0; - const currentDay = next.getUTCDay(); + const currentDay = next.getDay(); const daysUntilTarget = (targetDay - currentDay + 7) % 7; - next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7)); + next.setDate(next.getDate() + (daysUntilTarget || 7)); } break; case "biweekly": { - const targetDay = schedule.dayOfWeek ?? next.getUTCDay(); - const currentDay = next.getUTCDay(); + const targetDay = schedule.dayOfWeek ?? next.getDay(); + const currentDay = next.getDay(); let daysUntilTarget = (targetDay - currentDay + 7) % 7; // ensure at least one full week gap to make it biweekly daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7; - next.setUTCDate(next.getUTCDate() + daysUntilTarget); + next.setDate(next.getDate() + daysUntilTarget); } break; case "monthly": { - const targetDay = schedule.dayOfMonth ?? next.getUTCDate(); + const targetDay = schedule.dayOfMonth ?? next.getDate(); // Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months. - next.setUTCDate(1); - next.setUTCMonth(next.getUTCMonth() + 1); + next.setDate(1); + next.setMonth(next.getMonth() + 1); const lastDay = getLastDayOfMonth(next); - next.setUTCDate(Math.min(targetDay, lastDay)); + next.setDate(Math.min(targetDay, lastDay)); } break; case "custom": { const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays; - next.setUTCDate(next.getUTCDate() + days); + next.setDate(next.getDate() + days); } break; default: // Fallback to periodDays - next.setUTCDate(next.getUTCDate() + periodDays); + next.setDate(next.getDate() + periodDays); } - next.setUTCHours(hours, minutes, seconds, ms); + next.setHours(hours, minutes, seconds, ms); return fromZonedTime(next, timezone); } function getLastDayOfMonth(date: Date): number { - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate(); + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); } diff --git a/api/src/jobs/rollover.ts b/api/src/jobs/rollover.ts index 9f7c797..8843e3f 100644 --- a/api/src/jobs/rollover.ts +++ b/api/src/jobs/rollover.ts @@ -4,8 +4,10 @@ import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js"; const addDaysInTimezone = (date: Date, days: number, timezone: string) => { const zoned = toZonedTime(date, timezone); - zoned.setUTCDate(zoned.getUTCDate() + days); - zoned.setUTCHours(0, 0, 0, 0); + // Advance by calendar days in the user's local timezone, then normalize + // to local midnight before converting back to UTC for storage. + zoned.setDate(zoned.getDate() + days); + zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); }; diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts index e177d11..5b54e55 100644 --- a/api/src/routes/dashboard.ts +++ b/api/src/routes/dashboard.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from "fastify"; import { getUserMidnightFromDateOnly } from "../allocator.js"; +import { getUserTimezone } from "../services/user-context.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -273,9 +274,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => { app.get("/crisis-status", async (req) => { const userId = req.userId; const now = new Date(); - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); const { getUserMidnight } = await import("../allocator.js"); const userNow = getUserMidnight(userTimezone, now); diff --git a/api/src/routes/fixed-plans.ts b/api/src/routes/fixed-plans.ts index d115ce6..4571912 100644 --- a/api/src/routes/fixed-plans.ts +++ b/api/src/routes/fixed-plans.ts @@ -6,6 +6,7 @@ import { getUserMidnight, getUserMidnightFromDateOnly, } from "../allocator.js"; +import { getUserTimezone } from "../services/user-context.js"; type RateLimitRouteOptions = { config: { @@ -90,9 +91,7 @@ const fixedPlansRoutes: FastifyPluginAsync = async ( if (!plan) { return reply.code(404).send({ message: "Plan not found" }); } - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); await app.prisma.fixedPlan.update({ where: { id: planId }, @@ -662,9 +661,7 @@ const fixedPlansRoutes: FastifyPluginAsync = async ( return reply.code(400).send({ message: "Invalid payload" }); } const userId = req.userId; - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); const amountMode = parsed.data.amountMode ?? "fixed"; if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) { @@ -751,9 +748,7 @@ const fixedPlansRoutes: FastifyPluginAsync = async ( } const id = String((req.params as any).id); const userId = req.userId; - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId }, diff --git a/api/src/routes/variable-categories.ts b/api/src/routes/variable-categories.ts index a9686c5..5d596df 100644 --- a/api/src/routes/variable-categories.ts +++ b/api/src/routes/variable-categories.ts @@ -1,6 +1,7 @@ -import type { FastifyPluginAsync, FastifyInstance } from "fastify"; +import type { FastifyPluginAsync } from "fastify"; import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; +import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js"; type RateLimitRouteOptions = { config: { @@ -76,52 +77,6 @@ async function assertPercentTotal( } } -async function getLatestBudgetSession(app: FastifyInstance, userId: string) { - return app.prisma.budgetSession.findFirst({ - where: { userId }, - orderBy: { periodStart: "desc" }, - }); -} - -async function ensureBudgetSession( - app: FastifyInstance, - userId: string, - fallbackAvailableCents = 0 -) { - const existing = await getLatestBudgetSession(app, userId); - if (existing) return existing; - - const now = new Date(); - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)); - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)); - - return app.prisma.budgetSession.create({ - data: { - userId, - periodStart: start, - periodEnd: end, - totalBudgetCents: BigInt(Math.max(0, fallbackAvailableCents)), - allocatedCents: 0n, - fundedCents: 0n, - availableCents: BigInt(Math.max(0, fallbackAvailableCents)), - }, - }); -} - -async function ensureBudgetSessionAvailableSynced( - app: FastifyInstance, - userId: string, - availableCents: number -) { - const normalizedAvailableCents = BigInt(Math.max(0, Math.trunc(availableCents))); - const session = await ensureBudgetSession(app, userId, Number(normalizedAvailableCents)); - if ((session.availableCents ?? 0n) === normalizedAvailableCents) return session; - return app.prisma.budgetSession.update({ - where: { id: session.id }, - data: { availableCents: normalizedAvailableCents }, - }); -} - const variableCategoriesRoutes: FastifyPluginAsync = async ( app, opts @@ -132,15 +87,7 @@ const variableCategoriesRoutes: FastifyPluginAsync { try { @@ -169,18 +116,10 @@ const variableCategoriesRoutes: FastifyPluginAsync { const exists = await tx.variableCategory.findFirst({ @@ -271,7 +210,7 @@ const variableCategoriesRoutes: FastifyPluginAsync s + Number(c.balanceCents ?? 0n), 0); - await ensureBudgetSessionAvailableSynced(app, userId, totalBalance); + await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance); return reply.send({ ok: true, @@ -296,7 +235,7 @@ const variableCategoriesRoutes: FastifyPluginAsync s + Number(c.balanceCents ?? 0n), 0); const availableCents = totalBalance; - await ensureBudgetSessionAvailableSynced(app, userId, availableCents); + await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents); const targetMap = new Map(); for (const t of parsed.data.targets) { diff --git a/api/src/server.ts b/api/src/server.ts index 899ea47..6d66c6a 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -10,6 +10,11 @@ import { PrismaClient } from "@prisma/client"; import { z } from "zod"; import { getUserMidnightFromDateOnly } from "./allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { + computeDepositShares, + computeOverdraftShares, + computeWithdrawShares, +} from "./services/category-shares.js"; import healthRoutes from "./routes/health.js"; import sessionRoutes from "./routes/session.js"; import userRoutes from "./routes/user.js"; @@ -429,199 +434,26 @@ function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone: switch (frequency) { case "weekly": - zoned.setUTCDate(zoned.getUTCDate() + 7); + zoned.setDate(zoned.getDate() + 7); break; case "biweekly": - zoned.setUTCDate(zoned.getUTCDate() + 14); + zoned.setDate(zoned.getDate() + 14); break; case "monthly": { - const targetDay = zoned.getUTCDate(); - const nextMonth = zoned.getUTCMonth() + 1; - const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12); - const nextMonthIndex = nextMonth % 12; - const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate(); - zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); + const targetDay = zoned.getDate(); + zoned.setDate(1); + zoned.setMonth(zoned.getMonth() + 1); + const lastDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate(); + zoned.setDate(Math.min(targetDay, lastDay)); break; } default: return base; } - zoned.setUTCHours(0, 0, 0, 0); + zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } -function jsonBigIntSafe(obj: unknown) { - return JSON.parse( - JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)) - ); -} - -type PercentCategory = { - id: string; - percent: number; - balanceCents: bigint | null; -}; - -function computePercentShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const shares = categories.map((cat) => { - const raw = (amountCents * cat.percent) / percentTotal; - const floored = Math.floor(raw); - return { - id: cat.id, - balanceCents: Number(cat.balanceCents ?? 0n), - share: floored, - frac: raw - floored, - }; - }); - - let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); - shares - .slice() - .sort((a, b) => b.frac - a.frac) - .forEach((s) => { - if (remainder > 0) { - s.share += 1; - remainder -= 1; - } - }); - - if (shares.some((s) => s.share > s.balanceCents)) { - return { ok: false as const, reason: "insufficient_balances" }; - } - - return { ok: true as const, shares }; -} - -function computeWithdrawShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const working = categories.map((cat) => ({ - id: cat.id, - percent: cat.percent, - balanceCents: Number(cat.balanceCents ?? 0n), - share: 0, - })); - - let remaining = Math.max(0, Math.floor(amountCents)); - let safety = 0; - - while (remaining > 0 && safety < 1000) { - safety += 1; - const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0); - if (eligible.length === 0) break; - - const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0); - if (totalPercent <= 0) break; - - const provisional = eligible.map((cat) => { - const raw = (remaining * cat.percent) / totalPercent; - const floored = Math.floor(raw); - return { - id: cat.id, - raw, - floored, - remainder: raw - floored, - }; - }); - - let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0); - let leftovers = remaining - sumBase; - provisional - .slice() - .sort((a, b) => b.remainder - a.remainder) - .forEach((p) => { - if (leftovers > 0) { - p.floored += 1; - leftovers -= 1; - } - }); - - let allocatedThisRound = 0; - for (const p of provisional) { - const entry = working.find((w) => w.id === p.id); - if (!entry) continue; - const take = Math.min(p.floored, entry.balanceCents); - if (take > 0) { - entry.balanceCents -= take; - entry.share += take; - allocatedThisRound += take; - } - } - - remaining -= allocatedThisRound; - if (allocatedThisRound === 0) break; - } - - if (remaining > 0) { - return { ok: false as const, reason: "insufficient_balances" }; - } - - return { - ok: true as const, - shares: working.map((c) => ({ id: c.id, share: c.share })), - }; -} - -function computeOverdraftShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const shares = categories.map((cat) => { - const raw = (amountCents * cat.percent) / percentTotal; - const floored = Math.floor(raw); - return { - id: cat.id, - share: floored, - frac: raw - floored, - }; - }); - - let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); - shares - .slice() - .sort((a, b) => b.frac - a.frac) - .forEach((s) => { - if (remainder > 0) { - s.share += 1; - remainder -= 1; - } - }); - - return { ok: true as const, shares }; -} - -function computeDepositShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const shares = categories.map((cat) => { - const raw = (amountCents * cat.percent) / percentTotal; - const floored = Math.floor(raw); - return { - id: cat.id, - share: floored, - frac: raw - floored, - }; - }); - - let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); - shares - .slice() - .sort((a, b) => b.frac - a.frac) - .forEach((s) => { - if (remainder > 0) { - s.share += 1; - remainder -= 1; - } - }); - - return { ok: true as const, shares }; -} - const DEFAULT_VARIABLE_CATEGORIES = [ { name: "Essentials", percent: 50, priority: 10, isSavings: false }, { name: "Savings", percent: 30, priority: 20, isSavings: true }, diff --git a/api/test-income-overdue.sh b/api/test-income-overdue.sh deleted file mode 100644 index f3ecfe7..0000000 --- a/api/test-income-overdue.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Login and save cookie -echo " Logging in..." -curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null 2>&1 - -# Check current plans -echo " Plans BEFORE income:" -curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}' - -# Post $1000 income -echo -e "\n Posting $1000 income..." -RESULT=$(curl -s -b cookies.txt -X POST http://localhost:8080/api/income \ - -H "Content-Type: application/json" \ - -d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test income\"}") - -echo "$RESULT" | jq '{overduePaid, fixedAllocations, variableAllocations}' - -# Check plans after -echo -e "\n Plans AFTER income:" -curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}' - -rm -f cookies.txt diff --git a/api/test-monthly-income.cjs b/api/test-monthly-income.cjs deleted file mode 100644 index d621f12..0000000 --- a/api/test-monthly-income.cjs +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Test script for monthly income payday calculations with TIMEZONE awareness - * Run with: node test-monthly-income.cjs - * - * This replicates the actual allocator.ts logic including timezone handling - */ - -// Simulating date-fns-tz behavior (simplified for testing) -function toZonedTime(date, timezone) { - // For testing, we'll use a simple offset approach - // In real code, this uses proper timezone rules - const utc = date.getTime(); - const tzOffset = getTimezoneOffset(timezone, date); - return new Date(utc + tzOffset); -} - -function fromZonedTime(date, timezone) { - const tzOffset = getTimezoneOffset(timezone, date); - return new Date(date.getTime() - tzOffset); -} - -// Simplified timezone offset (real implementation uses IANA database) -function getTimezoneOffset(timezone, date) { - const offsets = { - 'UTC': 0, - 'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity) - 'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST - 'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST - }; - return offsets[timezone] || 0; -} - -function getUserMidnight(timezone, date = new Date()) { - const zonedDate = toZonedTime(date, timezone); - zonedDate.setHours(0, 0, 0, 0); - return fromZonedTime(zonedDate, timezone); -} - -const frequencyDays = { - weekly: 7, - biweekly: 14, - monthly: 30, // Not used for monthly anymore -}; - -function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') { - const normalizedFrom = getUserMidnight(timezone, fromDate); - const nextPayDate = getUserMidnight(timezone, firstIncomeDate); - - // Get the target day in the USER'S timezone - const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); - const targetDay = zonedFirstIncome.getDate(); - - let iterations = 0; - while (nextPayDate < normalizedFrom) { - if (frequency === 'monthly') { - // Work in user's timezone for month advancement - const zonedPayDate = toZonedTime(nextPayDate, timezone); - zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); - const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); - zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); - zonedPayDate.setHours(0, 0, 0, 0); - const newPayDate = fromZonedTime(zonedPayDate, timezone); - nextPayDate.setTime(newPayDate.getTime()); - } else { - nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]); - } - iterations++; - } - - return { nextPayDate, iterations, targetDay }; -} - -function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') { - let count = 0; - const nextPayDate = getUserMidnight(timezone, firstIncomeDate); - const normalizedStart = getUserMidnight(timezone, startDate); - const normalizedEnd = getUserMidnight(timezone, endDate); - - const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); - const targetDay = zonedFirstIncome.getDate(); - - const advanceByPeriod = () => { - if (frequency === 'monthly') { - const zonedPayDate = toZonedTime(nextPayDate, timezone); - zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); - const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); - zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); - zonedPayDate.setHours(0, 0, 0, 0); - const newPayDate = fromZonedTime(zonedPayDate, timezone); - nextPayDate.setTime(newPayDate.getTime()); - } else { - nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]); - } - }; - - while (nextPayDate < normalizedStart) { - advanceByPeriod(); - } - - while (nextPayDate < normalizedEnd) { - count++; - advanceByPeriod(); - } - - return Math.max(1, count); -} - -// Helper to format dates -const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); -const fmtISO = (d) => d.toISOString().split('T')[0]; - -console.log('═══════════════════════════════════════════════════════════════'); -console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)'); -console.log('═══════════════════════════════════════════════════════════════\n'); - -// Test 1: Monthly payday on the 15th - America/New_York -console.log('TEST 1: Monthly payday on the 15th (America/New_York)'); -console.log('─────────────────────────────────────'); -const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC -const today = new Date('2025-12-20T05:00:00.000Z'); - -const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York'); -console.log(`First income (UTC): ${firstPayday15.toISOString()}`); -console.log(`Today (UTC): ${today.toISOString()}`); -console.log(`Target day: ${result1.targetDay}th of month`); -console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`); -console.log(`Iterations: ${result1.iterations}`); -console.log(`✓ Should be Jan 15, 2026 in EST\n`); - -// Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary -console.log('TEST 2: Timezone boundary edge case'); -console.log('─────────────────────────────────────'); -// If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST) -const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z'); -const todayLA = new Date('2025-12-20T08:00:00.000Z'); - -const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles'); -console.log(`Timezone: America/Los_Angeles`); -console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`); -console.log(`Target day: ${resultLA.targetDay}th of month`); -console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`); -console.log(`✓ Target day should be 15, not 14 or 16\n`); - -// Test 3: Compare UTC vs timezone-aware for same "15th" payday -console.log('TEST 3: UTC vs Timezone-aware comparison'); -console.log('─────────────────────────────────────'); -const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC -const fromDate = new Date('2025-06-01T00:00:00.000Z'); - -const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC'); -const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York'); -const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo'); - -console.log(`Date stored: ${sameDate.toISOString()}`); -console.log(`From date: ${fromDate.toISOString()}`); -console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`); -console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`); -console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`); -console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`); - -// Test 4: Monthly payday on 31st with day clamping -console.log('TEST 4: Monthly payday on 31st (day clamping)'); -console.log('─────────────────────────────────────'); -const firstPayday31 = new Date('2025-01-31T05:00:00.000Z'); -console.log(`First payday: Jan 31, 2025`); - -let tempDate = getUserMidnight('America/New_York', firstPayday31); -console.log(`\nPayday progression:`); -for (let i = 0; i < 6; i++) { - const zoned = toZonedTime(tempDate, 'America/New_York'); - console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`); - - // Advance by month - zoned.setMonth(zoned.getMonth() + 1); - const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate(); - zoned.setDate(Math.min(31, maxDay)); - zoned.setHours(0, 0, 0, 0); - tempDate = fromZonedTime(zoned, 'America/New_York'); -} -console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`); - -// Test 5: Count pay periods with timezone -console.log('TEST 5: Count pay periods (timezone-aware)'); -console.log('─────────────────────────────────────'); -const firstIncome = new Date('2025-01-15T05:00:00.000Z'); -const nowDate = new Date('2025-12-20T05:00:00.000Z'); -const billDue = new Date('2026-03-01T05:00:00.000Z'); - -const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York'); -const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC'); - -console.log(`Now: Dec 20, 2025`); -console.log(`Bill due: Mar 1, 2026`); -console.log(`First income: Jan 15, 2025`); -console.log(`Periods (EST): ${periodsEST}`); -console.log(`Periods (UTC): ${periodsUTC}`); -console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`); - -// Test 6: OLD vs NEW comparison (with timezone) -console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone'); -console.log('─────────────────────────────────────'); -const startDate = new Date('2025-01-15T05:00:00.000Z'); -let oldDate = new Date(startDate); -let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York'); -let newDate = new Date(newResult.nextPayDate); - -console.log('Month | OLD (30 days) | NEW (timezone) | Drift'); -console.log('──────┼────────────────┼─────────────────┼───────'); - -for (let i = 0; i < 12; i++) { - oldDate.setDate(oldDate.getDate() + 30); - - // For new method, advance one month from previous - const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next - newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York'); - newDate = newResult.nextPayDate; - - const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000)); - console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`); -} - -console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!'); -console.log('✓ OLD method drifts 5-6 days early after 12 months\n'); - -console.log('═══════════════════════════════════════════════════════════════'); -console.log(' ALL TESTS COMPLETE - Timezone handling verified'); -console.log('═══════════════════════════════════════════════════════════════'); - diff --git a/api/test-overdue-api.sh b/api/test-overdue-api.sh deleted file mode 100644 index 0ba76e4..0000000 --- a/api/test-overdue-api.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -# Test overdue payment via API endpoint - -# Login to get token -echo "🔐 Logging in..." -LOGIN_RESPONSE=$(curl -s -X POST http://localhost:8080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"test@skymoney.com","password":"password123"}') - -TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"token":"[^"]*' | cut -d'"' -f4) - -if [ -z "$TOKEN" ]; then - echo "❌ Login failed" - exit 1 -fi - -echo "✅ Logged in successfully" - -# Check current state -echo -e "\n📋 Checking current plans..." -curl -s -X GET http://localhost:8080/api/fixed-plans \ - -H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}' - -# Post $500 income - should pay $500 to overdue (was $1000, now $500 remaining) -echo -e "\n💰 Posting $500 income..." -INCOME_RESPONSE=$(curl -s -X POST http://localhost:8080/api/income \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCents": 50000, - "postedAt": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'", - "note": "Test income for overdue" - }') - -echo $INCOME_RESPONSE | jq '.' - -# Check state after income -echo -e "\n📋 Checking plans after income..." -curl -s -X GET http://localhost:8080/api/fixed-plans \ - -H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}' diff --git a/api/test-overdue-payment.cjs b/api/test-overdue-payment.cjs deleted file mode 100644 index 3622461..0000000 --- a/api/test-overdue-payment.cjs +++ /dev/null @@ -1,133 +0,0 @@ -// Script to post test income and verify overdue payment -const { PrismaClient } = require('@prisma/client'); -const { randomUUID } = require('crypto'); - -async function main() { - const prisma = new PrismaClient(); - - try { - const user = await prisma.user.findUnique({ - where: { email: 'test@skymoney.com' } - }); - - if (!user) { - console.log('❌ Test user not found. Run create-test-user.cjs first.'); - return; - } - - console.log('✅ Found test user:', user.email); - - // Check overdue status BEFORE posting income - const plansBefore = await prisma.fixedPlan.findMany({ - where: { userId: user.id }, - select: { - id: true, - name: true, - totalCents: true, - fundedCents: true, - isOverdue: true, - overdueAmount: true, - }, - }); - - console.log('\n📋 Plans BEFORE income:'); - for (const plan of plansBefore) { - console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`); - } - - // Post $1000 income - should pay $500 to overdue first, then allocate $500 normally - const incomeAmount = 100000; // $1000 in cents - console.log(`\n💰 Posting income: $${incomeAmount / 100}`); - - const incomeId = randomUUID(); - const now = new Date().toISOString(); - - // Simulate what the allocateIncome function does - const result = await prisma.$transaction(async (tx) => { - await tx.incomeEvent.create({ - data: { - id: incomeId, - userId: user.id, - postedAt: now, - amountCents: BigInt(incomeAmount), - note: 'Test income for overdue payment', - }, - }); - - // Find overdue plans - const overduePlans = await tx.fixedPlan.findMany({ - where: { - userId: user.id, - isOverdue: true, - overdueAmount: { gt: 0 }, - }, - orderBy: { overdueSince: 'asc' }, - }); - - console.log(`\n🔍 Found ${overduePlans.length} overdue plan(s)`); - - let remaining = incomeAmount; - for (const plan of overduePlans) { - if (remaining <= 0) break; - - const overdueAmount = Number(plan.overdueAmount); - const amountToPay = Math.min(overdueAmount, remaining); - - console.log(` Paying $${amountToPay / 100} to ${plan.name} (was $${overdueAmount / 100} overdue)`); - - // Create allocation - await tx.allocation.create({ - data: { - userId: user.id, - kind: 'fixed', - toId: plan.id, - amountCents: BigInt(amountToPay), - incomeId, - }, - }); - - // Update plan - const newOverdueAmount = overdueAmount - amountToPay; - await tx.fixedPlan.update({ - where: { id: plan.id }, - data: { - fundedCents: (plan.fundedCents ?? 0n) + BigInt(amountToPay), - currentFundedCents: (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay), - overdueAmount: newOverdueAmount, - isOverdue: newOverdueAmount > 0, - lastFundingDate: new Date(now), - }, - }); - - remaining -= amountToPay; - } - - return { remaining }; - }); - - console.log(`\n💵 Remaining after overdue payments: $${result.remaining / 100}`); - - // Check overdue status AFTER posting income - const plansAfter = await prisma.fixedPlan.findMany({ - where: { userId: user.id }, - select: { - id: true, - name: true, - totalCents: true, - fundedCents: true, - isOverdue: true, - overdueAmount: true, - }, - }); - - console.log('\n📋 Plans AFTER income:'); - for (const plan of plansAfter) { - console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`); - } - - } finally { - await prisma.$disconnect(); - } -} - -main(); diff --git a/api/test-simple.sh b/api/test-simple.sh deleted file mode 100644 index 0c7da77..0000000 --- a/api/test-simple.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -echo " Logging in..." -curl -s -c cookies.txt -X POST http://localhost:8080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null - -echo " Plans BEFORE:" -curl -s -b cookies.txt http://localhost:8080/api/fixed-plans - -echo -e "\n\n Posting $1000 income..." -curl -s -b cookies.txt -X POST http://localhost:8080/api/income \ - -H "Content-Type: application/json" \ - -d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test\"}" - -echo -e "\n\n Plans AFTER:" -curl -s -b cookies.txt http://localhost:8080/api/fixed-plans - -rm -f cookies.txt diff --git a/api/tests/helpers.ts b/api/tests/helpers.ts index 48077e7..a907545 100644 --- a/api/tests/helpers.ts +++ b/api/tests/helpers.ts @@ -32,8 +32,8 @@ export async function resetUser(userId: string) { export async function ensureUser(userId: string) { await prisma.user.upsert({ where: { id: userId }, - update: {}, - create: { id: userId, email: `${userId}@demo.local` }, + update: { timezone: "UTC" }, + create: { id: userId, email: `${userId}@demo.local`, timezone: "UTC" }, }); } diff --git a/api/tests/payment-rollover.test.ts b/api/tests/payment-rollover.test.ts index 381f551..85814a5 100644 --- a/api/tests/payment-rollover.test.ts +++ b/api/tests/payment-rollover.test.ts @@ -40,27 +40,28 @@ function calculateNextDueDateLikeServer( switch (frequency) { case "weekly": - zoned.setUTCDate(zoned.getUTCDate() + 7); + zoned.setDate(zoned.getDate() + 7); break; case "biweekly": - zoned.setUTCDate(zoned.getUTCDate() + 14); + zoned.setDate(zoned.getDate() + 14); break; case "monthly": { - const targetDay = zoned.getUTCDate(); - const nextMonth = zoned.getUTCMonth() + 1; - const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12); - const nextMonthIndex = nextMonth % 12; + const targetDay = zoned.getDate(); + zoned.setDate(1); + zoned.setMonth(zoned.getMonth() + 1); const lastDay = new Date( - Date.UTC(nextYear, nextMonthIndex + 1, 0) - ).getUTCDate(); - zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); + zoned.getFullYear(), + zoned.getMonth() + 1, + 0 + ).getDate(); + zoned.setDate(Math.min(targetDay, lastDay)); break; } default: return base; } - zoned.setUTCHours(0, 0, 0, 0); + zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } diff --git a/api/tests/rollover.test.ts b/api/tests/rollover.test.ts index e76da10..bb6b5bd 100644 --- a/api/tests/rollover.test.ts +++ b/api/tests/rollover.test.ts @@ -23,7 +23,8 @@ describe("rolloverFixedPlans", () => { select: { id: true }, }); - const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z"); + // Rollover job only processes after 6 AM in the user's timezone. + const results = await rolloverFixedPlans(prisma, "2025-01-10T10:00:00Z"); const match = results.find((r) => r.planId === plan.id); expect(match?.cyclesAdvanced).toBe(1); expect(match?.deficitCents).toBe(4000); @@ -48,7 +49,8 @@ describe("rolloverFixedPlans", () => { select: { id: true }, }); - const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z"); + // Rollover job only processes after 6 AM in the user's timezone. + const results = await rolloverFixedPlans(prisma, "2025-02-05T10:00:00Z"); const match = results.find((r) => r.planId === plan.id); expect(match?.cyclesAdvanced).toBe(2); expect(match?.carryForwardCents).toBe(2000); diff --git a/api/tests/variable-categories.guard.test.ts b/api/tests/variable-categories.guard.test.ts index 4f4c088..70e378a 100644 --- a/api/tests/variable-categories.guard.test.ts +++ b/api/tests/variable-categories.guard.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; import appFactory from "./appFactory"; import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; let app: FastifyInstance; @@ -34,9 +35,12 @@ describe("Variable Categories guard (sum=100)", () => { }); it("rejects create that would push sum away from 100", async () => { + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ name: "Oops", percent: 10, isSavings: false, priority: 99 }); expect(res.statusCode).toBe(400); @@ -45,9 +49,12 @@ describe("Variable Categories guard (sum=100)", () => { it("rejects update that breaks the sum", async () => { const existing = await prisma.variableCategory.findFirst({ where: { userId: U } }); + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .patch(`/variable-categories/${existing!.id}`) .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ percent: 90 }); expect(res.statusCode).toBe(400); diff --git a/api/tests/variable-categories.manual-rebalance.test.ts b/api/tests/variable-categories.manual-rebalance.test.ts index f25ae15..9c85b72 100644 --- a/api/tests/variable-categories.manual-rebalance.test.ts +++ b/api/tests/variable-categories.manual-rebalance.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import appFactory from "./appFactory"; import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; let app: FastifyInstance; @@ -59,10 +60,13 @@ describe("manual rebalance", () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 + const csrf = randomUUID().replace(/-/g, ""); const postRes = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) - .send({ targets }); + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) + .send({ targets, forceLowerSavings: true }); expect(postRes.statusCode).toBe(200); expect(postRes.body?.availableCents).toBe(10_000); @@ -78,11 +82,14 @@ describe("manual rebalance", () => { it("rebalances when sums match available", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) - .send({ targets }); + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) + .send({ targets, forceLowerSavings: true }); expect(res.statusCode).toBe(200); expect(res.body?.ok).toBe(true); @@ -93,10 +100,13 @@ describe("manual rebalance", () => { it("rejects sum mismatch", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); @@ -108,10 +118,13 @@ describe("manual rebalance", () => { // savings to 500 (below 20% of 10000 = 2000) const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust targets[1].targetCents += 2; // total 10000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); @@ -120,6 +133,8 @@ describe("manual rebalance", () => { const resOk = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets, forceLowerSavings: true }); expect(resOk.statusCode).toBe(200); @@ -129,13 +144,16 @@ describe("manual rebalance", () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 })); targets[1].targetCents += 1; // sum 10000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); - expect(res.body?.code).toBe("OVER_80_PERCENT"); + expect(res.body?.code).toBe("OVER_80_CONFIRM_REQUIRED"); }); }); diff --git a/backups/skymoney_2026-01-16_204729.dump b/backups/skymoney_2026-01-16_204729.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205044.dump b/backups/skymoney_2026-01-16_205044.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205117.dump b/backups/skymoney_2026-01-16_205117.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205351.dump b/backups/skymoney_2026-01-16_205351.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205426.dump b/backups/skymoney_2026-01-16_205426.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205501.dump b/backups/skymoney_2026-01-16_205501.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205541.dump b/backups/skymoney_2026-01-16_205541.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205559.dump b/backups/skymoney_2026-01-16_205559.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205816.dump b/backups/skymoney_2026-01-16_205816.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205911.dump b/backups/skymoney_2026-01-16_205911.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210013.dump b/backups/skymoney_2026-01-16_210013.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210031.dump b/backups/skymoney_2026-01-16_210031.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210033.dump b/backups/skymoney_2026-01-16_210033.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210035.dump b/backups/skymoney_2026-01-16_210035.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210056.dump b/backups/skymoney_2026-01-16_210056.dump deleted file mode 100644 index 24fa3b7..0000000 Binary files a/backups/skymoney_2026-01-16_210056.dump and /dev/null differ diff --git a/backups/skymoney_2026-01-16_210413.dump b/backups/skymoney_2026-01-16_210413.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210432.dump b/backups/skymoney_2026-01-16_210432.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210619.dump b/backups/skymoney_2026-01-16_210619.dump deleted file mode 100644 index a8c5556..0000000 Binary files a/backups/skymoney_2026-01-16_210619.dump and /dev/null differ diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index df4fb20..0000000 --- a/bash.exe.stackdump +++ /dev/null @@ -1,55 +0,0 @@ -Stack trace: -Frame Function Args -0007FFFF4F30 000210060304 (0007FFFF5138, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304 -000000084002 00021006237D (000000000064, 000700000000, 000000000298, 000000000000) msys-2.0.dll+0x2237D -0007FFFF5640 0002100C1394 (000000000000, 000000000068, 000000000000, 0006FFFFFFB7) msys-2.0.dll+0x81394 -0007FFFF5AE0 0002100C1C19 (0007FFFF5810, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x81C19 -0007FFFF5AE0 00021006A1DE (000000004000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A1DE -0007FFFF5AE0 00021006A4A9 (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x2A4A9 -0007FFFF5AE0 000210193F2B (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x153F2B -0007FFFF5AE0 00010042DB65 (000A00000004, 0007FFFF5D74, 00010040DD62, 000000000000) bash.exe+0x2DB65 -0000FFFFFFFF 00010043C4F8 (0000000000C2, 000000000000, 000A001FF4B0, 000A000888B0) bash.exe+0x3C4F8 -000000000070 00010043E6BE (000000000000, 000000000001, 000000000000, 0007FFFF5D70) bash.exe+0x3E6BE -000000000070 000100441B06 (000700000001, 000A00000000, 0007FFFF5E60, 000000000000) bash.exe+0x41B06 -000000000070 000100441D36 (000100000000, 000A00000000, 0007FFFF5F3C, 0007FFFF5F38) bash.exe+0x41D36 -000000000001 000100443783 (00010000001F, 000000000001, 000A001FF310, 000A001FD9D0) bash.exe+0x43783 -0000FFFFFFFF 0001004195CA (00010046F680, 000000000000, 0000FFFFFFFF, 000A001FD9D0) bash.exe+0x195CA -000A001FF8E0 00010041AC6A (00000000001F, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A -000A001FF8E0 00010041CD1B (000100000000, 000000000000, 000200000000, 000A001FF8E0) bash.exe+0x1CD1B -0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FF8E0) bash.exe+0x179C7 -00000000015B 00010041AC6A (000000000000, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A -00000000015B 0001004180FB (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x180FB -000A001FD530 00010041C54A (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x1C54A -0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x179C7 -000A001FD4E0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A -000A001FD4E0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x1C50F -0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x179C7 -000A001FD490 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A -000A001FD490 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x1C50F -0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x179C7 -000A001FD440 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A -000A001FD440 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x1C50F -0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x179C7 -000A001FD3F0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A -000A001FD3F0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD3F0) bash.exe+0x1C50F -End of stack trace (more stack frames may be present) -Loaded modules: -000100400000 bash.exe -7FFF25310000 ntdll.dll -7FFF247D0000 KERNEL32.DLL -7FFF22E40000 KERNELBASE.dll -7FFF24010000 USER32.dll -7FFF22BD0000 win32u.dll -7FFF246C0000 GDI32.dll -000210040000 msys-2.0.dll -7FFF22CA0000 gdi32full.dll -7FFF22C00000 msvcp_win.dll -7FFF22A20000 ucrtbase.dll -7FFF23330000 advapi32.dll -7FFF249F0000 msvcrt.dll -7FFF24A90000 sechost.dll -7FFF24DA0000 RPCRT4.dll -7FFF229A0000 bcrypt.dll -7FFF221F0000 CRYPTBASE.DLL -7FFF23140000 bcryptPrimitives.dll -7FFF25180000 IMM32.DLL diff --git a/cookies.txt b/cookies.txt deleted file mode 100644 index 8418b9e..0000000 --- a/cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiOGEzN2U1Ny01M2QyLTQyMGYtOWYzZi1lYTdiNmEyM2JmNjgiLCJpYXQiOjE3NjM4NzEwMjl9.2H5loEHfxq33U1D5kS_Jt43CIoq_kKwKeHUTjjXQFzI diff --git a/cookies2.txt b/cookies2.txt deleted file mode 100644 index 8f0a3c9..0000000 --- a/cookies2.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODE5NzlmYS1mZTQwLTQxOTUtOGMwNC0xZWE2OWQ1NjRmZDQiLCJpYXQiOjE3NjM4NzEwOTl9.Jj4PzUGZuW2Eg-8vt1uJkLIcdDe5ghWFDCcvGYRHjSY diff --git a/cookies_debug.txt b/cookies_debug.txt deleted file mode 100644 index aef419c..0000000 --- a/cookies_debug.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZWQzMTNhMS05MTNiLTRmYzEtYTFiZC0wODhmOWFlY2FhNGUiLCJpYXQiOjE3NjM4NzE0MjN9.hMpW1tyRd-f5P6WuIm-56-PbdVo_7D9reYYYtcSgVn0 diff --git a/cookies_fixed.txt b/cookies_fixed.txt deleted file mode 100644 index 0ad602b..0000000 --- a/cookies_fixed.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2Mzl9.g_RurcGYjyJitJRLaPiVM5vAk5Nc94u05k5Rc83lV5k diff --git a/cookies_immediate.txt b/cookies_immediate.txt deleted file mode 100644 index 588e0aa..0000000 --- a/cookies_immediate.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3MWQxZjc1My1hYTI3LTQxOTEtYWIxNS05NjNjYWZlNjE2MGEiLCJpYXQiOjE3NjM4NzE1MDJ9.djJEIX3xz_typgayo_FcT8lvpWGXOjv8FLalmaPojQg diff --git a/cookies_login.txt b/cookies_login.txt deleted file mode 100644 index 95aa001..0000000 --- a/cookies_login.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2NTV9.K5pjWe9iUDawB7yrLNoQywvAmjgv33D2LTa6T50FKCY diff --git a/cookies_test.txt b/cookies_test.txt deleted file mode 100644 index ec7ac90..0000000 --- a/cookies_test.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyYmFhNzUwMS1iMzVkLTQyN2EtYTRlMy0wNTg0ZDczN2Y4MmYiLCJpYXQiOjE3NjM4NzE0NzN9.LpnakXIqy6brb_I59fFBTPhzyeZ6NoFpCiSvRQla-BE diff --git a/docs/api-phase1-move-log.md b/docs/api-phase1-move-log.md deleted file mode 100644 index 0fd1da4..0000000 --- a/docs/api-phase1-move-log.md +++ /dev/null @@ -1,61 +0,0 @@ -# API Phase 1 Move Log - -Date: 2026-03-15 -Scope: Move low-risk endpoints out of `api/src/server.ts` into dedicated route modules. - -## Route Registration Changes -- Registered session routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1518) -- Registered user routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1523) -- Registered health routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1524) - -## Endpoint Movements - -1. `GET /health` -- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:11) -- References: - - [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:10) - - [security-misconfiguration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-misconfiguration.test.ts:29) - -2. `GET /health/db` (non-production only) -- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:14) -- References: - - [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:15) - -3. `GET /auth/session` -- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:40) -- References: - - [useAuthSession.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useAuthSession.ts:23) - - [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:120) - -4. `POST /app/update-notice/ack` -- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:77) -- References: - - [App.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/App.tsx:33) - -5. `PATCH /me` -- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:50) -- References: - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:394) - - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:115) - -6. `PATCH /me/password` -- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:71) -- References: - - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:148) - - [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:57) - -7. `PATCH /me/income-frequency` -- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:112) -- References: - - Currently no frontend direct calls found by static search. - -8. `PATCH /user/config` -- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:135) -- References: - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:408) - - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:171) - -## Notes -- `server.ts` endpoint blocks for the above routes were removed to prevent duplicate registration. -- Existing path contracts were preserved (same method + path). -- `openPaths` and auth/CSRF hook behavior remain unchanged. diff --git a/docs/api-phase2-move-log.md b/docs/api-phase2-move-log.md deleted file mode 100644 index 956c9d9..0000000 --- a/docs/api-phase2-move-log.md +++ /dev/null @@ -1,75 +0,0 @@ -# API Phase 2 Move Log - -Date: 2026-03-16 -Scope: Move `auth` + `account` endpoints out of `api/src/server.ts` into a dedicated route module. - -## Route Registration Changes -- Registered auth/account routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:902) -- New route module: [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:111) - -## Endpoint Movements - -1. `POST /auth/register` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:115) -- References: - - [RegisterPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/RegisterPage.tsx:74) - - [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:54) - -2. `POST /auth/login` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:169) -- References: - - [LoginPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/LoginPage.tsx:55) - - [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:49) - -3. `POST /auth/logout` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:266) -- References: - - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:215) - - [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:53) - -4. `POST /auth/verify` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:278) -- References: - - [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:43) - - [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:93) - -5. `POST /auth/verify/resend` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:336) -- References: - - [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:65) - - [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:40) - -6. `POST /auth/forgot-password/request` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:391) -- References: - - [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:23) - - [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:45) - -7. `POST /auth/forgot-password/confirm` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:471) -- References: - - [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:31) - - [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:110) - -8. `POST /account/delete-request` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:537) -- References: - - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:251) - - [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:67) - -9. `POST /account/confirm-delete` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:596) -- References: - - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:270) - - [access-control.account-delete.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.account-delete.test.ts:60) - -10. `POST /auth/refresh` -- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:677) -- References: - - [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:26) - - [cryptographic-failures.runtime.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/cryptographic-failures.runtime.test.ts:71) - -## Notes -- `server.ts` auth/account endpoint blocks were removed to prevent duplicate registration. -- Existing path contracts were preserved (same method + path + response shapes). -- Existing auth helpers (`issueEmailToken`, cooldown checks, security logging, lockout tracking) are still sourced from `server.ts` and injected into the route module. diff --git a/docs/api-phase3-move-log.md b/docs/api-phase3-move-log.md deleted file mode 100644 index 6b1cb8f..0000000 --- a/docs/api-phase3-move-log.md +++ /dev/null @@ -1,65 +0,0 @@ -# API Phase 3 Move Log - -Date: 2026-03-16 -Scope: Move `variable-categories` endpoints out of `api/src/server.ts` into a dedicated route module. - -## Route Registration Changes -- Added variable-categories route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:18) -- Registered variable-categories routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:934) -- New canonical route module: [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:125) -- Removed inline variable-categories route block from `server.ts` to avoid duplicate endpoint registration. - -## Endpoint Movements - -1. `POST /variable-categories` -- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:129) -- References: - - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:12) - - [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:38) - -2. `PATCH /variable-categories/:id` -- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:165) -- References: - - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:13) - - [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:49) - -3. `DELETE /variable-categories/:id` -- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:202) -- References: - - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:14) - - [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:360) - -4. `POST /variable-categories/rebalance` -- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:219) -- References: - - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:15) - - [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:367) - -5. `GET /variable-categories/manual-rebalance` -- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:266) -- References: - - [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:24) - - [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:54) - -6. `POST /variable-categories/manual-rebalance` -- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:283) -- References: - - [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:25) - - [RebalancePage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/RebalancePage.tsx:148) - - [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:63) - -## Helper Ownership in Phase 3 -- Kept variable-category-specific helpers local to the route module: - - `assertPercentTotal` - - `getLatestBudgetSession` - - `ensureBudgetSession` - - `ensureBudgetSessionAvailableSynced` -- Reused shared server helper by injection: - - `computeDepositShares` injected from `server.ts` registration. -- Reused shared mutation throttling by injection: - - `mutationRateLimit` injected from `server.ts` registration. - -## Follow-ups To Revisit -1. Consolidate budget-session sync helpers into a shared service (`api/src/services/budget-session.ts`) once Phase 4 starts. -2. Standardize response error envelopes for variable-category routes (`message` vs `ok/code/message`) to reduce client branching. -3. Recheck `variable-categories.manual-rebalance.test.ts` over-80 error-code expectation versus current confirm-style behavior to keep tests aligned with product policy. diff --git a/docs/api-phase4-move-log.md b/docs/api-phase4-move-log.md deleted file mode 100644 index 776eff0..0000000 --- a/docs/api-phase4-move-log.md +++ /dev/null @@ -1,57 +0,0 @@ -# API Phase 4 Move Log - -Date: 2026-03-17 -Scope: Move `transactions` endpoints out of `api/src/server.ts` into a dedicated route module. - -## Route Registration Changes -- Added transactions route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:19) -- Registered transactions routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:938) -- New canonical route module: [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:38) -- Removed inline transactions route blocks from `server.ts` to avoid duplicate registration: - - `POST /transactions` block - - `GET/PATCH/DELETE /transactions` block - -## Endpoint Movements - -1. `POST /transactions` -- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:42) -- References: - - [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:34) - - [payment-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/payment-rollover.test.ts:42) - - [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:139) - -2. `GET /transactions` -- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:465) -- References: - - [useTransactionsQuery.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactionsQuery.tsx:41) - - [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/transactions.ts:27) - - [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:71) - -3. `PATCH /transactions/:id` -- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:601) -- References: - - [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:63) - - [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:189) - -4. `DELETE /transactions/:id` -- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:659) -- References: - - [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:75) - - [SpendPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/SpendPage.tsx:296) - -## Helper Ownership in Phase 4 -- Shared helper injection from `server.ts`: - - `mutationRateLimit` - - `computeDepositShares` - - `computeWithdrawShares` - - `computeOverdraftShares` - - `calculateNextDueDate` - - `toBig` - - `parseCurrencyToCents` -- Route-local helper: - - `isDate` query validator - -## Follow-ups To Revisit -1. Split `POST /transactions` into smaller internal handlers (`variable_spend` vs `fixed_payment`) after endpoint migration phases complete. -2. Replace broad `any` shapes in transaction where-clause/order composition with a typed query builder to reduce regression risk. -3. Reconcile test expectations for plan overdraft behavior (`OVERDRAFT_PLAN` vs current fixed-payment reconciliation semantics) before behavior changes in later phases. diff --git a/docs/api-phase5-move-log.md b/docs/api-phase5-move-log.md deleted file mode 100644 index a7a71b7..0000000 --- a/docs/api-phase5-move-log.md +++ /dev/null @@ -1,121 +0,0 @@ -# API Phase 5 Move Log - -Date: 2026-03-17 -Scope: Move `fixed-plans` endpoints out of `api/src/server.ts` into a dedicated route module. - -## Route Registration Changes -- Added fixed-plans route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:20) -- Registered fixed-plans routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:948) -- New canonical route module: [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71) -- Removed inline fixed-plans route blocks from `server.ts` to avoid duplicate registration: - - `PATCH /fixed-plans/:id/early-funding` - - `POST /fixed-plans/:id/attempt-final-funding` - - `PATCH /fixed-plans/:id/mark-unpaid` - - `POST /fixed-plans/:id/fund-from-available` - - `POST /fixed-plans/:id/catch-up-funding` - - `POST /fixed-plans` - - `PATCH /fixed-plans/:id` - - `DELETE /fixed-plans/:id` - - `POST /fixed-plans/:id/true-up-actual` - - `GET /fixed-plans/due` - - `POST /fixed-plans/:id/pay-now` - -## Endpoint Movements - -1. `PATCH /fixed-plans/:id/early-funding` -- Original: `server.ts` line 1414 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71) -- References: - - [EarlyPaymentPromptModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyPaymentPromptModal.tsx:34) - - [EarlyFundingModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyFundingModal.tsx:19) - -2. `POST /fixed-plans/:id/attempt-final-funding` -- Original: `server.ts` line 1475 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:131) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:58) - - [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:219) - -3. `PATCH /fixed-plans/:id/mark-unpaid` -- Original: `server.ts` line 1635 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:287) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:84) - -4. `POST /fixed-plans/:id/fund-from-available` -- Original: `server.ts` line 1674 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:325) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:95) - - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:461) - -5. `POST /fixed-plans/:id/catch-up-funding` -- Original: `server.ts` line 1828 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:478) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:106) - - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:512) - -6. `POST /fixed-plans` -- Original: `server.ts` line 2036 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:659) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:39) - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:449) - - [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:43) - -7. `PATCH /fixed-plans/:id` -- Original: `server.ts` line 2122 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:747) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:41) - - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:502) - -8. `DELETE /fixed-plans/:id` -- Original: `server.ts` line 2239 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:866) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:42) - - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:443) - -9. `POST /fixed-plans/:id/true-up-actual` -- Original: `server.ts` line 2285 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:911) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:108) - - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:549) - - [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:79) - -10. `GET /fixed-plans/due` -- Original: `server.ts` line 2429 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1054) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:45) - - [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:363) - -11. `POST /fixed-plans/:id/pay-now` -- Original: `server.ts` line 2495 -- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1118) -- References: - - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:77) - - [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:649) - - [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:170) - -## Helper Ownership in Phase 5 -- Shared helper injection from `server.ts`: - - `mutationRateLimit` - - `computeDepositShares` - - `computeWithdrawShares` - - `calculateNextDueDate` - - `toBig` -- Route-local helper: - - `DAY_MS` constant for date-window computations - - `PlanBody` / `PlanAmountMode` zod schemas - -## Verification -1. Build -- `cd api && npm run build` ✅ - -2. Focused tests -- `cd api && npm run test -- tests/fixed-plans.estimated-true-up.test.ts tests/payment-rollover.test.ts tests/transactions.test.ts` -- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions. diff --git a/docs/api-phase6-move-log.md b/docs/api-phase6-move-log.md deleted file mode 100644 index f0e1591..0000000 --- a/docs/api-phase6-move-log.md +++ /dev/null @@ -1,100 +0,0 @@ -# API Phase 6 Move Log - -Date: 2026-03-17 -Scope: Move `income`, `budget`, and `payday` endpoints out of `api/src/server.ts` into dedicated route modules. - -## Route Registration Changes -- Added income route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:21) -- Added payday route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:22) -- Added budget route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:23) -- Registered income routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946) -- Registered payday routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:949) -- Registered budget routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:953) -- Removed inline route blocks from `server.ts` to avoid duplicate registration: - - `POST /income` - - `GET /income/history` - - `POST /income/preview` - - `GET /payday/status` - - `POST /payday/dismiss` - - `POST /budget/allocate` - - `POST /budget/fund` - - `POST /budget/reconcile` - -## Endpoint Movements - -1. `POST /income` -- Original: `server.ts` line 1382 -- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:31) -- References: - - [useIncome.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncome.ts:27) - - [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:71) - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:479) - - [income.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.test.ts:19) - - [income.integration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.integration.test.ts:59) - -2. `GET /income/history` -- Original: `server.ts` line 1421 -- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:78) -- References: - - [useIncomeHistory.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomeHistory.ts:15) - -3. `POST /income/preview` -- Original: `server.ts` line 1459 -- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:115) -- References: - - [useIncomePreview.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomePreview.ts:16) - -4. `GET /payday/status` -- Original: `server.ts` line 1483 -- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:29) -- References: - - [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:35) - -5. `POST /payday/dismiss` -- Original: `server.ts` line 1586 -- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:135) -- References: - - [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:54) - -6. `POST /budget/allocate` -- Original: `server.ts` line 1597 -- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:43) -- References: - - [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:58) - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:8) - -7. `POST /budget/fund` -- Original: `server.ts` line 1624 -- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:69) -- References: - - [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:65) - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:473) - -8. `POST /budget/reconcile` -- Original: `server.ts` line 1657 -- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:98) -- References: - - [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:72) - - [ReconcileSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/ReconcileSettings.tsx:8) - -## Helper Ownership in Phase 6 -- Shared helper injection from `server.ts`: - - `mutationRateLimit` - - `computeDepositShares` - - `computeWithdrawShares` - - `isProd` flag for environment-sensitive logging -- Route-local schemas/helpers: - - `income.ts`: `AllocationOverrideSchema` - - `budget.ts`: `BudgetBody`, `ReconcileBody` - - `payday.ts`: local debug logger and query schema - -## Notes -- Removed legacy unregistered `api/src/routes/income-preview.ts` to avoid duplicate endpoint logic drift. - -## Verification -1. Build -- `cd api && npm run build` ✅ - -2. Focused tests -- `cd api && npm run test -- tests/income.test.ts tests/income.integration.test.ts` -- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions. diff --git a/docs/api-phase7-move-log.md b/docs/api-phase7-move-log.md deleted file mode 100644 index d57474a..0000000 --- a/docs/api-phase7-move-log.md +++ /dev/null @@ -1,48 +0,0 @@ -# API Phase 7 Move Log - -Date: 2026-03-17 -Scope: Move dashboard read endpoints out of `api/src/server.ts` into a dedicated route module. - -## Route Registration Changes -- Added dashboard route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24) -- Registered dashboard routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:944) -- New canonical route module: [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24) -- Removed inline dashboard route blocks from `server.ts` to avoid duplicate registration: - - `GET /dashboard` - - `GET /crisis-status` - -## Endpoint Movements - -1. `GET /dashboard` -- Original: `server.ts` line 1081 -- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24) -- References: - - [useDashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useDashboard.ts:85) - - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:172) - - [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:37) - - [security-logging-monitoring-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-logging-monitoring-failures.test.ts:48) - -2. `GET /crisis-status` -- Original: `server.ts` line 1330 -- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:273) -- References: - - No direct web/api wrapper references currently found via repo search. - - Endpoint remains available for API consumers and future UI wiring. - -## Helper Ownership in Phase 7 -- Route-local helpers in `dashboard.ts`: - - `monthKey` - - `monthLabel` - - `buildMonthBuckets` - - `DAY_MS` -- Reused allocator date helpers: - - static `getUserMidnightFromDateOnly` - - dynamic import of `getUserMidnight` and `calculateNextPayday` for parity with pre-move logic - -## Verification -1. Build -- `cd api && npm run build` ✅ - -2. Focused tests -- `cd api && npm run test -- tests/auth.routes.test.ts` -- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions. diff --git a/docs/api-phase8-move-log.md b/docs/api-phase8-move-log.md deleted file mode 100644 index 2607359..0000000 --- a/docs/api-phase8-move-log.md +++ /dev/null @@ -1,70 +0,0 @@ -# API Phase 8 Move Log - -Date: 2026-03-17 -Scope: Move `admin` and `site-access` endpoints out of `api/src/server.ts` into dedicated route modules. - -## Route Registration Changes -- Added site-access route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24) -- Added admin route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:25) -- Registered site-access routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946) -- Registered admin routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:960) -- New canonical route modules: - - [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:29) - - [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:10) -- Removed inline route blocks from `server.ts` to avoid duplicate registration: - - `GET /site-access/status` - - `POST /site-access/unlock` - - `POST /site-access/lock` - - `POST /admin/rollover` - -## Endpoint Movements - -1. `GET /site-access/status` -- Original: `server.ts` line 946 -- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:30) -- References: - - [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:10) - - [BetaGate.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/BetaGate.tsx:20) - - [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:22) - -2. `POST /site-access/unlock` -- Original: `server.ts` line 957 -- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:41) -- References: - - [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:14) - - [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:40) - -3. `POST /site-access/lock` -- Original: `server.ts` line 994 -- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:78) -- References: - - [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:18) - - [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:59) - -4. `POST /admin/rollover` -- Original: `server.ts` line 1045 -- Moved to [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:11) -- References: - - [access-control.admin-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.admin-rollover.test.ts:44) - -## Helper Ownership in Phase 8 -- Shared helper injection from `server.ts`: - - `authRateLimit` - - `mutationRateLimit` - - `hasSiteAccessBypass` - - `safeEqual` - - `isInternalClientIp` - - runtime config flags and cookie settings (`UNDER_CONSTRUCTION`, break-glass, cookie domain/secure, etc.) -- Route-local helpers/schemas: - - `site-access.ts`: unlock payload schema - - `admin.ts`: rollover payload schema -- Retained in `server.ts` by design for global hook behavior: - - site-access bypass token derivation and onRequest maintenance-mode enforcement - -## Verification -1. Build -- `cd api && npm run build` ✅ - -2. Focused tests -- `cd api && npm run test -- tests/access-control.admin-rollover.test.ts tests/security-misconfiguration.test.ts` -- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions. diff --git a/docs/api-refactor-lightweight-plan.md b/docs/api-refactor-lightweight-plan.md deleted file mode 100644 index 9b746c1..0000000 --- a/docs/api-refactor-lightweight-plan.md +++ /dev/null @@ -1,168 +0,0 @@ -# API Refactor Lightweight Plan - -## Goal -Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves. - -Current state (2026-03-17): -- `server.ts` still holds most business routes, but Phases 1-8 are complete. -- Completed move logs: - - `docs/api-phase1-move-log.md` - - `docs/api-phase2-move-log.md` - - `docs/api-phase3-move-log.md` - - `docs/api-phase4-move-log.md` - - `docs/api-phase5-move-log.md` - - `docs/api-phase6-move-log.md` - - `docs/api-phase7-move-log.md` - - `docs/api-phase8-move-log.md` - -## Refactor Guardrails -1. Keep route behavior identical while moving code. -2. Move one domain at a time; do not mix domains in one PR. -3. Do not redesign architecture (no repositories/DI/container work). -4. Extract only duplicated logic into shared helpers/services. -5. Preserve global hooks and plugin registration in `server.ts` until final cleanup. -6. Keep response shapes stable (`ok`, `code`, `message`, etc.) during moves. -7. Require tests to pass after each move phase. - -## Canonical Source Rule -`server.ts` is the canonical route logic right now. - -When moving a domain: -1. Copy current logic from `server.ts` into the domain route module. -2. Register the module. -3. Remove the original block from `server.ts`. -4. Confirm no duplicate route registrations remain. - -## Shared Helpers (Phase 0) -Create and/or finish these helpers to keep endpoint moves lightweight: - -1. `api/src/services/user-context.ts` -- `getUserTimezone(...)` -- Removes repeated user timezone lookup logic. - -2. `api/src/services/category-shares.ts` -- `computePercentShares(...)` -- `computeWithdrawShares(...)` -- `computeOverdraftShares(...)` -- `computeDepositShares(...)` -- Centralizes repeated category-share math. - -3. `api/src/services/budget-session.ts` -- `getLatestBudgetSession(...)` -- `ensureBudgetSession(...)` -- `ensureBudgetSessionAvailableSynced(...)` -- Prevents session/value drift bugs. - -4. `api/src/services/api-errors.ts` -- Standard typed error object and response body builder. -- Keeps endpoint error handling consistent. - -## Progress Snapshot -Completed: -1. Phase 1: low-risk endpoints (`health`, `session`, `me`, `user/config`). -2. Phase 2: `auth` + `account` endpoints. -3. Phase 3: `variable-categories` endpoints. -4. Phase 4: `transactions` endpoints. -5. Phase 5: `fixed-plans` endpoints. -6. Phase 6: `income`, `budget`, `payday` endpoints. -7. Phase 7: `dashboard` + `crisis-status`. -8. Phase 8: `admin` + site access endpoints. - -Remaining: -1. Phase 9: final cleanup and helper consolidation. - -## Remaining Plan (Detailed) - -### Phase 5: Fixed Plans Domain -Move these routes out of `server.ts` into `api/src/routes/fixed-plans.ts` (or split module if needed): -1. `PATCH /fixed-plans/:id/early-funding` -2. `POST /fixed-plans/:id/attempt-final-funding` -3. `PATCH /fixed-plans/:id/mark-unpaid` -4. `POST /fixed-plans/:id/fund-from-available` -5. `POST /fixed-plans/:id/catch-up-funding` -6. `POST /fixed-plans` -7. `PATCH /fixed-plans/:id` -8. `DELETE /fixed-plans/:id` -9. `POST /fixed-plans/:id/true-up-actual` -10. `GET /fixed-plans/due` -11. `POST /fixed-plans/:id/pay-now` - -Primary risk: -- Payment/funding workflows are tightly coupled with available budget math and rollover rules. - -Test focus: -- `api/tests/fixed-plans*.test.ts` -- `api/tests/payment-rollover.test.ts` - -### Phase 6: Income, Budget, Payday Domain -Move these routes into a dedicated module (e.g., `api/src/routes/budget-income.ts`): -1. `POST /income` -2. `GET /income/history` -3. `POST /income/preview` -4. `POST /budget/allocate` -5. `POST /budget/fund` -6. `POST /budget/reconcile` -7. `GET /payday/status` -8. `POST /payday/dismiss` - -Primary risk: -- Budget-session synchronization and allocator side effects. - -Test focus: -- Income/budget allocation tests -- Any tests asserting payday status/dismiss behavior - -### Phase 7: Dashboard Read Domain -Move read endpoints into `api/src/routes/dashboard.ts`: -1. `GET /dashboard` -2. `GET /crisis-status` - -Primary risk: -- Derived numbers diverging between dashboard and rebalance/session APIs. - -Test focus: -- Dashboard API contract checks and UI smoke verification. - -### Phase 8: Admin and Site Access Domain -Move operational endpoints into `api/src/routes/admin.ts` and/or `api/src/routes/site-access.ts`: -1. `POST /admin/rollover` -2. `GET /site-access/status` -3. `POST /site-access/unlock` -4. `POST /site-access/lock` - -Primary risk: -- Lockout/maintenance flow regressions and accidental open access. - -Test focus: -- Site access flow tests -- Admin rollover auth/permission checks - -### Phase 9: Final Cleanup -1. Remove dead helper duplicates from `server.ts` and route modules. -2. Consolidate common helpers into `api/src/services/*`. -3. Normalize error envelopes where safe (no contract change unless explicitly planned). -4. Re-run full API/security suites and perform deployment smoke checks. - -## Reference Audit Requirement (Per Move) -For every endpoint moved: -1. Record original location in `server.ts`. -2. Record new route module location. -3. Record frontend references (`web/src/api/*`, hooks, pages). -4. Record test references. -5. Add a phase move log under `docs/api-phaseX-move-log.md`. - -## Verification Steps (Per Phase) -1. Build: -- `cd api && npm run build` -2. Run focused tests for moved domain. -3. Run security suites relevant to moved endpoints. -4. Deploy. -5. Run production endpoint smoke checks for the moved routes. -6. Confirm logs show expected status codes (no unexplained 401/403/500 shifts). - -## Definition of Done per Phase -1. Endpoints compile and register once. -2. Existing tests pass. -3. No route path/method changes. -4. No response contract changes. -5. `server.ts` net line count decreases. diff --git a/exporting b/exporting deleted file mode 100644 index e69de29..0000000 diff --git a/irregular_cookies.txt b/irregular_cookies.txt deleted file mode 100644 index 1db519a..0000000 --- a/irregular_cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlNzkzNTNiNS0zNTY1LTQ5YTQtYjE2NC1jNzU4NWVkNmU2MTgiLCJpYXQiOjE3NjM4NzE5ODV9.uoibcsSuH15ZS2bjtssHtt2aOcJF2RdDAHK_ynIeIco diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 46fb1a6..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "SkyMoney", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/regular_cookies.txt b/regular_cookies.txt deleted file mode 100644 index c82ff17..0000000 --- a/regular_cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzUyZjRhMy1hNjM5LTQyMWEtODI2Ny0yMGM0NTdhMDk3MmUiLCJpYXQiOjE3NjM4NzE5NzN9.ZslzE0_K4Mxsmgg37PsFKUP-lfSdYZaQfUqTf3o5m5Y diff --git a/tests-results-for-OWASP/A01-Broken-Access-Control.md b/tests-results-for-OWASP/A01-Broken-Access-Control.md deleted file mode 100644 index 538fa6b..0000000 --- a/tests-results-for-OWASP/A01-Broken-Access-Control.md +++ /dev/null @@ -1,62 +0,0 @@ -# A01: Broken Access Control - -Last updated: March 1, 2026 - -## Findings - -1. Cross-account delete risk in `/account/confirm-delete`. -2. `AUTH_DISABLED` mode allows header-based impersonation (`x-user-id`) and must be tightly controlled. - -## Fixes implemented - -1. `/account/confirm-delete` is now session-bound: -- Lookup uses `req.userId`. -- Request `email` must match session user's email. -- Mismatch returns `403`. - -2. Insecure auth guard added: -- `AUTH_DISABLED=true` now requires `ALLOW_INSECURE_AUTH_FOR_DEV=true` unless `NODE_ENV=test`. - -3. `/admin/rollover` hardened: -- Still requires `AUTH_DISABLED=true`. -- Now also requires request IP to be internal/private. - -## Test coverage - -1. `api/tests/access-control.account-delete.test.ts` -- Verifies cross-account deletion attempt is denied (`403`). -- Verifies victim account is not deleted. - -2. `api/tests/auth.routes.test.ts` -- Verifies protected route rejects unauthenticated access. -- Verifies spoofed `x-user-id` is rejected when auth is enabled. -- Verifies login/session/logout behavior with CSRF handling. -- Verifies login lockout behavior. - -3. `api/tests/access-control.admin-rollover.test.ts` -- Verifies unauthenticated access to `/admin/rollover` is denied when `AUTH_DISABLED=false`. -- Verifies authenticated access still receives `403` when `AUTH_DISABLED=false`. -- Verifies `/admin/rollover` rejects non-internal client IP when `AUTH_DISABLED=true`. -- Verifies `/admin/rollover` allows internal client IP when `AUTH_DISABLED=true`. - -## Run commands - -From `api/`: - -```bash -npm test -- tests/auth.routes.test.ts tests/access-control.account-delete.test.ts tests/access-control.admin-rollover.test.ts -``` - -## Expected results - -1. All three test files pass. -2. No access granted to protected routes without valid auth cookie/JWT. -3. Spoofed `x-user-id` does not bypass auth when `AUTH_DISABLED=false`. -4. Cross-account delete attempt fails with `403`. -5. `/admin/rollover` remains inaccessible from public/non-internal clients. - -## Production configuration requirements - -1. `AUTH_DISABLED=false` -2. `ALLOW_INSECURE_AUTH_FOR_DEV=false` -3. Strong `JWT_SECRET` and `COOKIE_SECRET` diff --git a/tests-results-for-OWASP/A02-Security-Misconfiguration.md b/tests-results-for-OWASP/A02-Security-Misconfiguration.md deleted file mode 100644 index a4b8c8d..0000000 --- a/tests-results-for-OWASP/A02-Security-Misconfiguration.md +++ /dev/null @@ -1,89 +0,0 @@ -# A02: Security Misconfiguration - -Last updated: March 1, 2026 - -## Findings addressed - -1. SMTP transport debug logging enabled in all environments. -2. Production CORS had a fail-open branch when configured origins were empty. -3. Missing explicit anti-framing headers at edge. -4. Docker compose exposed Postgres/API on all host interfaces. - -## Fixes implemented - -1. SMTP transport hardening in API: -- `requireTLS` now respects config (`SMTP_REQUIRE_TLS`). -- SMTP debug/logger are disabled in production (`!isProd` only). - -2. CORS production behavior tightened: -- Removed fail-open branch for empty configured origins. -- Production now allows only explicitly configured origins. - -3. Edge header hardening: -- Added `X-Frame-Options: DENY`. -- Added `Content-Security-Policy: frame-ancestors 'none'`. - -4. Compose exposure reduction: -- Bound Postgres and API ports to localhost only. - -## Files changed - -1. `api/src/server.ts` -2. `Caddyfile.prod` -3. `Caddyfile.dev` -4. `deploy/nginx/skymoneybudget.com.conf` -5. `docker-compose.yml` - -## Verification - -### Automated security regression tests - -Command (A01 regression): - -```bash -cd api -npm test -- auth.routes.test.ts access-control.account-delete.test.ts -``` - -Verified output (provided from host run): - -- Test Files: `2 passed (2)` -- Tests: `6 passed (6)` -- Start time: `16:39:35` - -Command (A02 dedicated suite): - -```bash -cd api -npx vitest --run -c vitest.security.config.ts -``` - -Verified output: - -- Test Files: `1 passed (1)` -- Tests: `5 passed (5)` -- Suite: `tests/security-misconfiguration.test.ts` - -Coverage in dedicated suite: - -1. Production CORS allowlist enforcement (allowed origin accepted, denied origin does not receive allow headers). -2. SMTP production mailer options disable debug/logger. -3. Runtime CORS preflight headers validated for allowed origins (`allow-origin`, `allow-credentials`, `vary`). -4. Edge config files contain anti-framing headers (`X-Frame-Options`, `frame-ancestors` CSP). -5. `docker-compose.yml` binds Postgres/API ports to localhost only. - -### Expected operational checks after deploy - -1. Unauthenticated `GET /dashboard` returns `401`. -2. Spoofed `x-user-id` does not bypass auth when `AUTH_DISABLED=false`. -3. `/admin/rollover` remains inaccessible from public network. -4. Response headers include anti-framing policy. - -## Residual notes - -1. Keep production env pinned to: -- `AUTH_DISABLED=false` -- `ALLOW_INSECURE_AUTH_FOR_DEV=false` - -2. Keep CORS origins explicitly configured in production: -- `CORS_ORIGIN` and/or `CORS_ORIGINS`. diff --git a/tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md b/tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md deleted file mode 100644 index 4e06fce..0000000 --- a/tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md +++ /dev/null @@ -1,77 +0,0 @@ -# A03: Software Supply Chain Failures - -Last updated: March 1, 2026 - -## Findings addressed - -1. Production dependency vulnerabilities were present in both API and web lockfiles. -2. Deploy pipeline had no explicit dependency vulnerability gate. - -## Fixes implemented - -1. Dependency remediation: -- Ran `npm audit fix` in `api` and `web`. -- Revalidated production dependencies are clean with `npm audit --omit=dev`. - -2. Pipeline hardening: -- Added supply-chain check step in deploy workflow: - - `npm ci` + `npm audit --omit=dev --audit-level=high` for API and web. -- Updated checkout action from broad major tag to explicit release tag `v4.2.2`. - -## Files changed - -1. `.gitea/workflows/deploy.yml` -2. `api/package-lock.json` -3. `web/package-lock.json` -4. `api/tests/software-supply-chain-failures.test.ts` -5. `api/vitest.security.config.ts` - -## Verification - -### Production dependency vulnerability scans - -Command: - -```bash -cd api -npm audit --omit=dev --audit-level=high -cd ../web -npm audit --omit=dev --audit-level=high -``` - -Verified output: - -- `found 0 vulnerabilities` (api) -- `found 0 vulnerabilities` (web) - -### Workflow policy verification (automated) - -Command: - -```bash -cd api -npx vitest run -c vitest.security.config.ts tests/software-supply-chain-failures.test.ts -``` - -Verified output: - -- Test Files: `1 passed (1)` -- Tests: `2 passed (2)` - -Coverage in policy suite: - -1. Deploy workflow includes dependency gate step for API and web. -2. Workflow requires `npm ci` and `npm audit --omit=dev --audit-level=high` for both projects. -3. `actions/checkout` remains pinned to an explicit release tag. - -## Residual risks (not yet fully eliminated) - -1. Base image tags are still mutable (`node:20-bookworm-slim`, `postgres:15`) and not digest-pinned. -2. `actions/checkout` is pinned to a release tag, not a full commit SHA. -3. No artifact signing/attestation verification (e.g., cosign/SLSA) in current deploy pipeline. - -## Recommended next hardening steps - -1. Pin container images by immutable digest in `Dockerfile`/`docker-compose.yml`. -2. Pin workflow actions to full commit SHAs. -3. Add SBOM generation and signature/attestation verification before deploy. diff --git a/tests-results-for-OWASP/A04-Cryptographic-Failures.md b/tests-results-for-OWASP/A04-Cryptographic-Failures.md deleted file mode 100644 index a7e07d4..0000000 --- a/tests-results-for-OWASP/A04-Cryptographic-Failures.md +++ /dev/null @@ -1,71 +0,0 @@ -# A04: Cryptographic Failures - -Last updated: March 1, 2026 - -## Findings addressed - -1. Production origin could be configured as non-HTTPS. -2. JWT configuration did not explicitly constrain issuer/audience/algorithm. -3. Missing explicit env contract for JWT issuer/audience values. - -## Fixes implemented - -1. Production HTTPS origin enforcement: -- Added production guard requiring `APP_ORIGIN` to start with `https://`. - -2. JWT hardening in Fastify: -- Explicit signing algorithm set to `HS256`. -- JWT signing includes configured `iss` and `aud`. -- JWT verification enforces: - - allowed algorithm (`HS256`) - - allowed issuer - - allowed audience - -3. Env schema expanded: -- Added `JWT_ISSUER` and `JWT_AUDIENCE` with secure defaults: - - `skymoney-api` - - `skymoney-web` - -## Files changed - -1. `api/src/env.ts` -2. `api/src/server.ts` -3. `.env.example` -4. `api/tests/cryptographic-failures.test.ts` -5. `api/tests/cryptographic-failures.runtime.test.ts` -6. `api/vitest.security.config.ts` - -## Verification - -Commands: - -```bash -cd api -npx vitest run -c vitest.security.config.ts tests/cryptographic-failures.test.ts tests/cryptographic-failures.runtime.test.ts -``` - -Verified output: - -- Test Files: `2 passed (2)` -- Tests: `7 passed (7)` - -Dedicated A04 checks in `cryptographic-failures.test.ts`: - -1. Production env rejects non-HTTPS `APP_ORIGIN`. -2. JWT issuer/audience defaults resolve as expected. -3. Fastify JWT plugin is registered with explicit algorithm + issuer + audience constraints. - -Runtime adversarial checks in `cryptographic-failures.runtime.test.ts`: - -1. Token with invalid issuer is rejected (`401`). -2. Token with invalid audience is rejected (`401`). -3. Unsigned token (`alg=none`) is rejected (`401`). -4. Token with valid signature + expected issuer/audience is accepted on protected auth refresh route. - -## Required production env values - -1. `APP_ORIGIN=https://...` -2. `JWT_SECRET` strong 32+ chars -3. `COOKIE_SECRET` strong 32+ chars -4. `JWT_ISSUER=skymoney-api` (or your approved value) -5. `JWT_AUDIENCE=skymoney-web` (or your approved value) diff --git a/tests-results-for-OWASP/A05-Injection.md b/tests-results-for-OWASP/A05-Injection.md deleted file mode 100644 index f739c72..0000000 --- a/tests-results-for-OWASP/A05-Injection.md +++ /dev/null @@ -1,50 +0,0 @@ -# A05: Injection - -Last updated: March 1, 2026 - -## Findings addressed - -1. API route used unsafe Prisma raw SQL helper (`$queryRawUnsafe`) for `/health/db`. -2. Restore script accepted unvalidated external inputs that could be abused for command/SQL injection scenarios during operational use. - -## Fixes implemented - -1. Replaced unsafe raw SQL helper: -- `app.prisma.$queryRawUnsafe("SELECT now() as now")` -- replaced with tagged, parameter-safe: -- `app.prisma.$queryRaw\`SELECT now() as now\`` - -2. Hardened `scripts/restore.sh` input handling: -- Added required file existence check for `BACKUP_FILE`. -- Added strict identifier validation for `RESTORE_DB` (`^[A-Za-z0-9_]+$`). - -## Files changed - -1. `api/src/server.ts` -2. `scripts/restore.sh` -3. `api/tests/injection-safety.test.ts` -4. `api/vitest.security.config.ts` - -## Verification - -Command: - -```bash -cd api -npx vitest run -c vitest.security.config.ts tests/injection-safety.test.ts -``` - -Verified output: - -- Test Files: `1 passed (1)` -- Tests: `2 passed (2)` - -Dedicated A05 checks in `injection-safety.test.ts`: - -1. Verifies no usage of `$queryRawUnsafe` / `$executeRawUnsafe` across API source files. -2. Executes `scripts/restore.sh` with adversarial `RESTORE_DB` input and verifies restore is rejected before DB commands execute. - -## Residual notes - -1. Main API query paths use Prisma query builder + zod input schemas (reduces SQL injection risk). -2. Operational scripts should remain restricted to trusted operators and environments. diff --git a/tests-results-for-OWASP/A06-Insecure-Design.md b/tests-results-for-OWASP/A06-Insecure-Design.md deleted file mode 100644 index 4e84506..0000000 --- a/tests-results-for-OWASP/A06-Insecure-Design.md +++ /dev/null @@ -1,54 +0,0 @@ -# A06: Insecure Design - -Last updated: March 1, 2026 - -## Findings addressed - -1. Sensitive email-token workflows did not enforce a cooldown between repeated code requests. -2. Verification and account-deletion flows needed tighter, route-specific throttling to reduce brute-force and abuse risk. - -## Fixes implemented - -1. Added explicit email-token cooldown guard in API: -- New helper `assertEmailTokenCooldown(userId, type, cooldownMs)`. -- Throws structured `429` error with code `EMAIL_TOKEN_COOLDOWN`. -- Sets `Retry-After` header when cooldown is active. - -2. Applied cooldown checks to both token issuance paths: -- `/auth/verify/resend` for signup verification codes. -- `/account/delete-request` for account deletion confirmation codes. - -3. Split and applied stricter rate-limit profiles for sensitive auth/account routes: -- `authRateLimit` on `/auth/register` and `/auth/login`. -- `codeVerificationRateLimit` on `/auth/verify` and `/account/confirm-delete`. -- `codeIssueRateLimit` on `/auth/verify/resend` and `/account/delete-request`. - -## Files changed - -1. `api/src/server.ts` -2. `api/tests/insecure-design.test.ts` -3. `api/vitest.security.config.ts` - -## Verification - -Command: - -```bash -cd api -npx vitest --run -c vitest.security.config.ts -``` - -Verified output: - -- Test Files: `4 passed (4)` -- Tests: `10 passed (10)` - -Dedicated A06 checks in `insecure-design.test.ts`: - -1. Runtime verification resend endpoint enforces cooldown (`/auth/register` issues token, then immediate `/auth/verify/resend` is blocked with `429` + `Retry-After`). -2. Runtime verification delete-request endpoint enforces cooldown (`/account/delete-request` second attempt returns `429` + `Retry-After`). -3. Runtime verification repeated invalid `/auth/verify` requests trigger route throttling (`429`). - -## Residual notes - -1. A06 runtime tests require PostgreSQL availability for user/token persistence paths. diff --git a/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md b/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md deleted file mode 100644 index 4709888..0000000 --- a/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md +++ /dev/null @@ -1,79 +0,0 @@ -# A07: Identification and Authentication Failures - -Last updated: March 1, 2026 - -## Findings addressed - -1. No explicit account lockout after repeated failed login attempts (brute-force risk). -2. Password policy for registration and password updates was too weak (length-only). - -## Fixes implemented - -1. Added login lockout controls: -- Tracks failed login attempts per normalized email in server memory. -- Locks login for a configurable window after threshold failures. -- Returns `429` with code `LOGIN_LOCKED` and `Retry-After` header during lockout. - -2. Added strong password policy: -- Minimum length `12`. -- Requires lowercase, uppercase, number, and symbol. -- Applied to: - - `/auth/register` password. - - `/me/password` new password. - -3. Added auth hardening configuration: -- `AUTH_MAX_FAILED_ATTEMPTS` (default: `5`) -- `AUTH_LOCKOUT_WINDOW_MS` (default: `900000`, 15 minutes) - -4. Added forgot-password hardening: -- Public reset request endpoint always returns a generic success response. -- Reset token issuance is restricted to verified users. -- Reset confirmation enforces strong password policy and one-time expiring token usage. -- Successful reset updates `passwordChangedAt` so existing sessions become invalid. - -## Files changed - -1. `api/src/server.ts` -2. `api/src/env.ts` -3. `.env.example` -4. `api/tests/auth.routes.test.ts` -5. `api/tests/identification-auth-failures.test.ts` -6. `api/vitest.security.config.ts` -7. `api/tests/forgot-password.security.test.ts` -8. `api/prisma/schema.prisma` -9. `api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql` - -## Verification - -Dedicated security suite command (executed): - -```bash -cd api -npx vitest --run -c vitest.security.config.ts -``` - -Verified output: - -- Test Files: `5 passed (5)` -- Tests: `12 passed (12)` - -Dedicated A07 checks in `identification-auth-failures.test.ts`: - -1. Runtime checks weak password rejection for registration and `/me/password` update flow. -2. Runtime checks lockout threshold/window behavior with configured `AUTH_MAX_FAILED_ATTEMPTS` and verifies `LOGIN_LOCKED` response + `Retry-After`. - -Runtime auth flow checks added in `auth.routes.test.ts`: - -1. Rejects weak passwords on registration. -2. Locks login after repeated failed attempts. - -Run this in an environment with PostgreSQL running to verify runtime behavior: - -```bash -cd api -npm test -- tests/auth.routes.test.ts tests/identification-auth-failures.test.ts -``` - -## Residual notes - -1. Current lockout state is in-memory per API instance; for horizontally scaled production, move lockout tracking to a shared store (Redis/DB) for consistent enforcement across instances. diff --git a/tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md b/tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md deleted file mode 100644 index 0db6d30..0000000 --- a/tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md +++ /dev/null @@ -1,49 +0,0 @@ -# A08: Software and Data Integrity Failures - -Last updated: March 1, 2026 - -## Findings addressed - -1. Backup/restore workflow did not verify backup artifact integrity before restoring. -2. Restores could proceed with tampered/corrupted dump files, risking silent data corruption. - -## Fixes implemented - -1. Added checksum artifact generation during backups: -- `scripts/backup.sh` now generates a SHA-256 checksum file next to each dump (`.sha256`). - -2. Added checksum verification before restore: -- `scripts/restore.sh` now requires `${BACKUP_FILE}.sha256`. -- Validates checksum format (64 hex chars). -- Computes runtime SHA-256 of backup file and blocks restore on mismatch. - -## Files changed - -1. `scripts/backup.sh` -2. `scripts/restore.sh` -3. `api/tests/software-data-integrity-failures.test.ts` -4. `api/vitest.security.config.ts` - -## Verification - -Command: - -```bash -cd api -npx vitest run -c vitest.security.config.ts tests/software-data-integrity-failures.test.ts -``` - -Verified output: - -- Test Files: `1 passed (1)` -- Tests: `2 passed (2)` - -Dedicated A08 checks in `software-data-integrity-failures.test.ts`: - -1. Executes `scripts/backup.sh` with stubbed `pg_dump` and verifies dump + `.sha256` artifact generation. -2. Executes `scripts/restore.sh` with tampered checksum and verifies restore is blocked before DB commands are invoked. - -## Residual notes - -1. This secures backup artifact integrity in operational scripts. -2. For CI/CD artifact integrity hardening, next step is attestation/signature verification for deployed build artifacts. diff --git a/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md b/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md deleted file mode 100644 index 22fbe9b..0000000 --- a/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md +++ /dev/null @@ -1,65 +0,0 @@ -# A09: Security Logging and Monitoring Failures - -Last updated: March 1, 2026 - -## Findings addressed - -1. Security-sensitive auth/account outcomes were not consistently logged as structured audit events. -2. Incident triage required better request correlation for failed auth/CSRF and account-deletion attempts. - -## Fixes implemented - -1. Added centralized structured security logging helper in API: -- `logSecurityEvent(req, event, outcome, details)` -- Includes request correlation fields (`requestId`, `ip`, `userAgent`). - -2. Added audit logging for critical security events: -- `auth.unauthenticated_request` (JWT auth failure) -- `csrf.validation` (CSRF check failure) -- `auth.register` success/blocked -- `auth.login` success/failure/blocked (including lockout cases) -- `auth.logout` success -- `auth.verify` success/failure -- `auth.verify_resend` success/failure/blocked -- `auth.password_reset.request` success/blocked -- `auth.password_reset.email` success/failure -- `auth.password_reset.confirm` success/failure -- `account.delete_request` success/failure/blocked -- `account.confirm_delete` success/failure/blocked - -3. Reduced sensitive data exposure in logs: -- Added email fingerprinting (`sha256` prefix) for event context instead of plain-text credentials. - -## Files changed - -1. `api/src/server.ts` -2. `api/tests/security-logging-monitoring-failures.test.ts` -3. `api/vitest.security.config.ts` -4. `api/tests/forgot-password.security.test.ts` -5. `SECURITY_FORGOT_PASSWORD.md` - -## Verification - -Command: - -```bash -cd api -npx vitest run -c vitest.security.config.ts tests/security-logging-monitoring-failures.test.ts -``` - -Verified output: - -- Test Files: `1 passed (1)` -- Tests: `2 passed (2)` - -Dedicated A09 checks in `security-logging-monitoring-failures.test.ts`: - -1. Runtime check emits structured `auth.unauthenticated_request` security event for protected-route access failures. -2. Runtime check emits structured `csrf.validation` security event for CSRF failures. -3. Validates correlation fields (`requestId`, `ip`, `outcome`) are present in emitted security events. -4. Runtime check emits `auth.password_reset.request` events and confirms raw token fields are absent. - -## Residual notes - -1. Event logs are currently emitted through app logs; ensure production log shipping/alerting (e.g., SIEM rules on repeated `auth.login` failure/blocked events). -2. Next step for A09 maturity is alert thresholds and automated incident notifications. diff --git a/tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md b/tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md deleted file mode 100644 index d6d9c18..0000000 --- a/tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md +++ /dev/null @@ -1,50 +0,0 @@ -# A10: Server-Side Request Forgery (SSRF) - -Last updated: March 1, 2026 - -## Findings addressed - -1. Production `APP_ORIGIN` previously enforced HTTPS but did not explicitly block localhost/private-network targets. -2. SSRF posture needed explicit verification that API runtime code does not introduce generic outbound HTTP clients for user-influenced targets. - -## Fixes implemented - -1. Hardened production `APP_ORIGIN` validation in env parsing: -- Requires valid URL format. -- Rejects localhost/private-network hosts: - - `localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `0.0.0.0`, `.local`. - -2. Added dedicated A10 verification tests: -- Rejects private/loopback `APP_ORIGIN` in production mode. -- Asserts API server source (`api/src/server.ts`) does not use generic outbound HTTP request clients (`fetch`, `axios`, `http.request`, `https.request`). - -## Files changed - -1. `api/src/env.ts` -2. `api/tests/server-side-request-forgery.test.ts` -3. `api/vitest.security.config.ts` - -## Verification - -Command: - -```bash -cd api -npx vitest run -c vitest.security.config.ts tests/server-side-request-forgery.test.ts -``` - -Verified output: - -- Test Files: `1 passed (1)` -- Tests: `3 passed (3)` - -Dedicated A10 checks in `server-side-request-forgery.test.ts`: - -1. Asserts production env parsing rejects multiple private/localhost `APP_ORIGIN` variants. -2. Asserts production env parsing accepts public HTTPS `APP_ORIGIN`. -3. Asserts API source code has no generic outbound HTTP client usage (`fetch`, `axios`, `http.request`, `https.request`) outside test scripts. - -## Residual notes - -1. Current API architecture has minimal outbound HTTP surface (primarily SMTP transport). -2. If future features add URL fetch/proxy/webhook integrations, enforce strict destination allowlists and network egress controls at implementation time. diff --git a/tests-results-for-OWASP/README.md b/tests-results-for-OWASP/README.md deleted file mode 100644 index 48f3d75..0000000 --- a/tests-results-for-OWASP/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# OWASP Test Results - -Last updated: March 2, 2026 - -This directory is the source of truth for SkyMoney OWASP validation work. - -## Purpose - -- Track implemented security tests and hardening changes. -- Define exact pre-deploy and post-deploy verification steps. -- Keep release evidence (commands, outputs, timestamps, pass/fail). - -## Files - -- `A01-Broken-Access-Control.md`: Findings, fixes, and verification for OWASP A01. -- `A02-Security-Misconfiguration.md`: Findings, fixes, and dedicated verification suite for OWASP A02. -- `A03-Software-Supply-Chain-Failures.md`: Dependency and pipeline supply-chain findings/fixes/verification. -- `A04-Cryptographic-Failures.md`: Crypto/session token hardening findings/fixes/verification. -- `A05-Injection.md`: Injection sink remediation and script input hardening verification. -- `A06-Insecure-Design.md`: Abuse-resistance design hardening (cooldowns + tighter route throttling). -- `A07-Identification-and-Authentication-Failures.md`: Login lockout and strong-password policy hardening. -- `A08-Software-and-Data-Integrity-Failures.md`: Backup/restore checksum integrity controls. -- `A09-Security-Logging-and-Monitoring-Failures.md`: Structured security event auditing for auth/account flows. -- `A10-Server-Side-Request-Forgery.md`: SSRF hardening and outbound-request surface validation. -- `post-deployment-verification-checklist.md`: Production smoke checks after each deploy. -- `evidence-log-template.md`: Copy/paste template for recording each verification run. -- `residual-risk-backlog.md`: Open non-blocking hardening items tracked release-to-release. -- `../docs/production-db-recovery-runbook.md`: Incident response + recovery + admin bootstrap runbook. - -## Current status - -1. A01 complete: implemented and tested. -2. A02 complete: implemented and tested. -3. A03 complete (initial hardening): implemented and tested. -4. A04 complete: implemented and tested. -5. A05 complete: implemented and tested. -6. A06 complete: implemented and tested. -7. A07 complete: implemented and tested. -8. A08 complete: implemented and tested. -9. A09 complete: implemented and tested. -10. A10 complete: implemented and tested. diff --git a/tests-results-for-OWASP/evidence-log-template.md b/tests-results-for-OWASP/evidence-log-template.md deleted file mode 100644 index b96f91b..0000000 --- a/tests-results-for-OWASP/evidence-log-template.md +++ /dev/null @@ -1,100 +0,0 @@ -# OWASP Verification Evidence Log Template - -## Run metadata - -- Date: -- Environment: `local` | `staging` | `production` -- App/API version (git SHA): -- Operator: -- Incident/reference ticket (if recovery event): - -## Environment flags - -- `NODE_ENV`: -- `AUTH_DISABLED`: -- `ALLOW_INSECURE_AUTH_FOR_DEV`: -- `COMPOSE_PROJECT_NAME`: -- `EXPECTED_PROD_DB_HOST`: -- `EXPECTED_PROD_DB_NAME`: - -## Commands executed - -1. -```bash -# command -``` -Output summary: - -2. -```bash -# command -``` -Output summary: - -3. -```bash -# command -``` -Output summary: - -4. -```bash -# command -``` -Output summary: - -## Recoverability Evidence - -- Current Postgres container: -- Mounted volume(s): -- Candidate old volume(s) inspected: -- Recoverable artifact found: `yes` | `no` -- Artifact location: -- Recovery decision: - -## Backup/Restore Drill Evidence - -- Latest backup file: -- Latest checksum file: -- Checksum verified: `yes` | `no` -- Restore test DB name: -- Restore succeeded: `yes` | `no` -- Row count checks performed: - -## Results - -- A01 protected route unauthenticated check: `pass` | `fail` -- A01 spoofed header check: `pass` | `fail` -- A01 admin rollover exposure check: `pass` | `fail` -- A01 automated suite (`auth` + `account-delete` + `admin-rollover`): `pass` | `fail` -- A02 dedicated suite (`security-misconfiguration`): `pass` | `fail` -- A03 dedicated suite (`software-supply-chain-failures`): `pass` | `fail` -- A04 dedicated suites (`cryptographic-failures*`): `pass` | `fail` -- A05 dedicated suite (`injection-safety`): `pass` | `fail` -- A06 dedicated suite (`insecure-design`): `pass` | `fail` -- A07 dedicated suites (`auth.routes` + `identification-auth-failures`): `pass` | `fail` -- A08 dedicated suite (`software-data-integrity-failures`): `pass` | `fail` -- A09 dedicated suite (`security-logging-monitoring-failures`): `pass` | `fail` -- A10 dedicated suite (`server-side-request-forgery`): `pass` | `fail` -- Non-DB security suite (`SECURITY_DB_TESTS=0`): `pass` | `fail` -- DB security suite (`SECURITY_DB_TESTS=1`): `pass` | `fail` - -## Findings - -- New issues observed: -- Regressions observed: -- Follow-up tickets: -- Data recovery status: -- Admin user bootstrap status: - -## Residual Risk Review - -- Reviewed `residual-risk-backlog.md`: `yes` | `no` -- Items accepted for this release: -- Items escalated/blocked: - -## Sign-off - -- Security reviewer: -- Engineering owner: -- Decision: `approved` | `blocked` diff --git a/tests-results-for-OWASP/post-deployment-verification-checklist.md b/tests-results-for-OWASP/post-deployment-verification-checklist.md deleted file mode 100644 index 1c9edae..0000000 --- a/tests-results-for-OWASP/post-deployment-verification-checklist.md +++ /dev/null @@ -1,161 +0,0 @@ -# Post-Deployment Verification Checklist - -Use this after every deploy (staging and production). - -## Preconditions - -1. Deployment completed successfully. -2. Migrations completed successfully. -3. Correct environment flags: -- `AUTH_DISABLED=false` -- `ALLOW_INSECURE_AUTH_FOR_DEV=false` -4. Test DB preflight (for DB-backed suites): -- `TEST_DATABASE_URL` points to a reachable PostgreSQL instance. -- `TEST_DATABASE_URL` database name is not `skymoney` and is clearly test-only (for example `skymoney_test`). -- `bash ./scripts/validate-test-db-target.sh` passes before DB-backed suites run. -- Example quick check: -```bash -echo "$TEST_DATABASE_URL" -``` -Expected: -- single valid URL value -- host/port match the intended test database (for local runs usually `127.0.0.1:5432`) -5. Compose/DB safety preflight: -- `COMPOSE_PROJECT_NAME=skymoney` is set for deploy runtime. -- `docker-compose.yml` volume `pgdata` is pinned to `skymoney_pgdata`. -- `scripts/validate-prod-db-target.sh` passes for current `.env`. -- `scripts/guard-prod-volume.sh` passes (or explicit one-time rebuild override is documented). -- deploy runbook acknowledges forbidden destructive commands in prod: - - `prisma migrate reset` - - `prisma migrate dev` - - `prisma db push --accept-data-loss` - - `docker compose down -v` / `docker-compose down -v` - -## Database recoverability and safety checks - -### 0) Capture current container and volume bindings - -```bash -docker ps --format '{{.Names}}' -docker inspect --format '{{json .Mounts}}' -docker volume ls | grep -E 'pgdata|skymoney|postgres' -PROD_DB_VOLUME_NAME=skymoney_pgdata ALLOW_EMPTY_PROD_VOLUME=0 DOCKER_CMD="sudo docker" bash ./scripts/guard-prod-volume.sh -``` - -Expected: -- production Postgres uses `skymoney_pgdata`. -- no unexpected new empty volume silently substituted. - -### 0.1) Validate latest backup artifact exists and verifies - -```bash -ls -lt /opt/skymoney/backups | head -LATEST_DUMP="$(ls -1t /opt/skymoney/backups/*.dump | head -n 1)" -sha256sum -c "${LATEST_DUMP}.sha256" -``` - -Expected: -- latest dump and checksum exist. -- checksum verification returns `OK`. - -### 0.2) Restore drill into isolated test DB (same VPS) - -```bash -RESTORE_DB="skymoney_restore_test_$(date +%Y%m%d%H%M)" \ -BACKUP_FILE="$LATEST_DUMP" \ -RESTORE_DATABASE_URL="postgres://:@127.0.0.1:5432/${RESTORE_DB}" \ -DATABASE_URL="postgres://:@127.0.0.1:5432/skymoney" \ -./scripts/restore.sh -``` - -Expected: -- restore completes without manual edits. -- key tables readable in restored DB. - -## A01 smoke checks - -Replace `${API_BASE}` with your deployed API base URL. - -### 1) Protected route requires auth - -```bash -curl -i "${API_BASE}/dashboard" -``` - -Expected: -- HTTP `401` -- response body includes `UNAUTHENTICATED` - -### 2) Spoofed identity header is ignored - -```bash -curl -i -H "x-user-id: spoofed-user-id" "${API_BASE}/dashboard" -``` - -Expected: -- HTTP `401` - -### 3) Admin rollover is not publicly callable - -```bash -curl -i -X POST "${API_BASE}/admin/rollover" \ - -H "Content-Type: application/json" \ - -d '{"dryRun":true}' -``` - -Expected: -- HTTP `401` or `403` (must not be publicly callable) - -## A09 smoke checks - -### 4) Security events are emitted for failed auth attempts - -Trigger a failed login attempt: - -```bash -curl -i -X POST "${API_BASE}/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email":"nonexistent@example.com","password":"WrongPass123!"}' -``` - -Expected: -- HTTP `401` -- API logs include a structured `securityEvent` for `auth.login` with `outcome=failure` -- log entry includes `requestId` - -## A10 smoke checks - -### 5) Production origin configuration is public and non-local - -Verify production env/config: - -- `APP_ORIGIN` uses public HTTPS host (not localhost/private IP ranges) - -Expected: -- API boots successfully with production env validation. - -## Automated regression checks - -Run in CI against a prod-like environment: - -```bash -cd api -npm test -- tests/auth.routes.test.ts tests/access-control.account-delete.test.ts tests/access-control.admin-rollover.test.ts -SECURITY_DB_TESTS=0 npx vitest run -c vitest.security.config.ts -SECURITY_DB_TESTS=1 npx vitest run -c vitest.security.config.ts -``` - -Expected: -- all tests pass - -Note: -- A06/A07 runtime suites require PostgreSQL availability. -- `SECURITY_DB_TESTS=0` runs non-DB security controls only. -- `SECURITY_DB_TESTS=1` includes DB-backed A06/A07/forgot-password suites. - -## Sign-off - -1. Record outputs in `evidence-log-template.md`. -2. Review open residual risks in `residual-risk-backlog.md`. -3. Record backup + restore drill evidence. -4. Mark release security check as pass/fail. diff --git a/tests-results-for-OWASP/residual-risk-backlog.md b/tests-results-for-OWASP/residual-risk-backlog.md deleted file mode 100644 index fa77569..0000000 --- a/tests-results-for-OWASP/residual-risk-backlog.md +++ /dev/null @@ -1,26 +0,0 @@ -# OWASP Residual Risk Backlog - -Last updated: March 2, 2026 - -Use this file to track non-blocking hardening items that remain after automated controls pass. - -## Open items - -| ID | OWASP | Residual risk | Status | -|---|---|---|---| -| RR-001 | A01 | Add explicit authorization integration tests for all future admin-only routes (deny-by-default coverage expansion). | Open | -| RR-002 | A02 | Add runtime CSP and full security-header verification from deployed edge stack (not only config checks). | Open | -| RR-003 | A03 | Add stronger supply-chain provenance controls (digest pinning, SLSA attestations, artifact signing). | Open | -| RR-004 | A04 | Add key rotation runbook validation and automated stale-key detection checks. | Open | -| RR-005 | A05 | Add static taint analysis or Semgrep policy bundle in CI for command/SQL injection sinks. | Open | -| RR-006 | A06 | Add abuse-case tests for account recovery and verification flows under distributed-IP pressure. | Open | -| RR-007 | A07 | Add MFA/WebAuthn roadmap tests once MFA is implemented; currently password+lockout only. | Open | -| RR-008 | A08 | Add signed backup manifests and restore provenance verification for operational artifacts. | Open | -| RR-009 | A09 | Add alerting pipeline assertions (SIEM/webhook delivery) in pre-prod smoke tests. | Open | -| RR-010 | A10 | Add egress firewall enforcement tests to complement application-layer SSRF guards. | Open | - -## Close criteria - -1. A concrete control is implemented and validated by an automated test or policy gate. -2. Evidence is attached in `evidence-log-template.md`. -3. Owning team marks item as Closed with date and link to implementation PR. diff --git a/transfer-rebalance-spec.md b/transfer-rebalance-spec.md deleted file mode 100644 index a336e8e..0000000 --- a/transfer-rebalance-spec.md +++ /dev/null @@ -1,39 +0,0 @@ -# Variable Pool Rebalance / Transfer Feature - -## Summary -- Allow users to redistribute current variable pool (BudgetSession.availableCents) by setting per-category dollar targets, without changing future percent-based allocations. -- Enforce guards: sum = available, savings floor confirm, non-negative, no single category >80%, warnings when lowering savings. -- New backend endpoint performs atomic balance updates and audit logging; fixed expenses unaffected. - -## API -- `POST /variable-categories/manual-rebalance` - - Body: `{ targets: [{ id, targetCents }], forceLowerSavings?: boolean }` - - Uses latest BudgetSession by periodStart; availableCents is the pool to balance. - - Validations: targets cover every category; non-negative; sum(targets)=available; each ≤80% of available; lowering savings or savings <20% requires `forceLowerSavings`. - - Transaction: update category balanceCents to targets; insert transaction(kind=`rebalance`, note snapshot); fixed plans untouched. - - Response: `{ ok: true, availableCents, categories: [{ id, balanceCents }] }`. - -## UI/UX -- New Rebalance page (Settings → Expenses tab entry point) showing availableCents and per-category balances. -- Editable dollar inputs with live total meter and inline errors for rule violations. -- Savings floor warning/confirm; optional helper to adjust one category and auto-scale others respecting floors. -- Confirmation modal summarizing before/after deltas. - -## Data / Logic -- Active session = latest BudgetSession for user. -- Rebalance acts on current variable pool only; future income remains percent-based. -- Savings floor default 20% of available; lowering requires confirmation flag. - -## Tests -- Sum=available happy path; savings unchanged. -- Lowering savings w/out flag → 400; with flag → OK. -- Savings total <20% w/out flag → 400; with flag → OK. -- >80% single category → 400. -- Sum mismatch → 400; negative target → 400. -- Negative existing balance allowed only if target >=0 (ending non-negative). -- Adjust-one helper unit: scaling respects floors. -- Audit entry created; fixed plans and percents unchanged. - -## Assumptions -- availableCents equals dashboard “Available” variable pool. -- No localization requirements for new errors. diff --git a/web/README.md b/web/README.md deleted file mode 100644 index d2e7761..0000000 --- a/web/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` diff --git a/web/src/pages/settings/CategoriesPage.tsx b/web/src/pages/settings/CategoriesPage.tsx deleted file mode 100644 index 9b1de90..0000000 --- a/web/src/pages/settings/CategoriesPage.tsx +++ /dev/null @@ -1,552 +0,0 @@ -// web/src/pages/settings/CategoriesPage.tsx -import { useMemo, useState, useEffect, type FormEvent } from "react"; -import { - DndContext, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, - arrayMove, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { useDashboard } from "../../hooks/useDashboard"; -import { Money } from "../../components/ui"; -import SettingsNav from "./_SettingsNav"; -import { - useCategories, - useCreateCategory, - useUpdateCategory, - useDeleteCategory, -} from "../../hooks/useCategories"; -import { useToast } from "../../components/Toast"; - -type Row = { - id: string; - name: string; - percent: number; - priority: number; - isSavings: boolean; - balanceCents: number; -}; - -function SumBadge({ total }: { total: number }) { - const tone = - total === 100 - ? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40" - : total < 100 - ? "bg-amber-500/10 text-amber-100 border border-amber-500/30" - : "bg-red-500/10 text-red-100 border border-red-500/40"; - const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over"; - return ( -
- {label}: {total}% -
- ); -} - -export default function SettingsCategoriesPage() { - const { data, isLoading, error, refetch, isFetching } = useDashboard(); - const cats = useCategories() as Row[]; - const createM = useCreateCategory(); - const updateM = useUpdateCategory(); - const deleteM = useDeleteCategory(); - const { push } = useToast(); - const normalizeName = (value: string) => value.trim().toLowerCase(); - const MIN_SAVINGS_PERCENT = 20; - - const total = useMemo( - () => cats.reduce((s, c) => s + c.percent, 0), - [cats], - ); - const savingsTotal = useMemo( - () => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0), - [cats] - ); - const remainingPercent = Math.max(0, 100 - total); - - // Drag ordering state (initially from priority) - const [order, setOrder] = useState([]); - useEffect(() => { - const sorted = cats - .slice() - .sort( - (a, b) => - a.priority - b.priority || a.name.localeCompare(b.name), - ); - const next = sorted.map((c) => c.id); - // Reset order when cats change in length or ids - if ( - order.length !== next.length || - next.some((id, i) => order[i] !== id) - ) { - setOrder(next); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cats.map((c) => c.id).join("|")]); - - // Add form state - const [name, setName] = useState(""); - const [percent, setPercent] = useState(""); - const [priority, setPriority] = useState(""); - const [isSavings, setIsSavings] = useState(false); - - const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0)); - const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0)); - const addDisabled = - !name.trim() || - parsedPercent <= 0 || - parsedPercent > 100 || - parsedPercent > remainingPercent || - createM.isPending; - - const onAdd = (e: FormEvent) => { - e.preventDefault(); - const normalizedName = normalizeName(name); - if (cats.some((c) => normalizeName(c.name) === normalizedName)) { - push("err", `Expense name '${normalizedName}' already exists`); - return; - } - const body = { - name: normalizedName, - percent: parsedPercent, - priority: parsedPriority, - isSavings, - }; - if (!body.name) return; - if (body.percent > remainingPercent) { - push("err", `Only ${remainingPercent}% is available right now.`); - return; - } - const nextTotal = total + body.percent; - const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0); - if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) { - push( - "err", - `Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)` - ); - return; - } - createM.mutate(body, { - onSuccess: () => { - push("ok", "Expense created"); - setName(""); - setPercent(""); - setPriority(""); - setIsSavings(false); - }, - onError: (err: any) => - push("err", err?.message ?? "Create failed"), - }); - }; - - const onEdit = (id: string, patch: Partial) => { - if (patch.name !== undefined) { - const normalizedName = normalizeName(patch.name); - if ( - cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName) - ) { - push("err", `Expense name '${normalizedName}' already exists`); - return; - } - patch.name = normalizedName; - } - if (patch.percent !== undefined) { - const current = cats.find((c) => c.id === id); - if (!current) return; - const sanitized = Math.max( - 0, - Math.min(100, Math.floor(patch.percent)), - ); - const nextTotal = total - current.percent + sanitized; - if (nextTotal > 100) { - push("err", `Updating this would push totals to ${nextTotal}%.`); - return; - } - patch.percent = sanitized; - } - if (patch.priority !== undefined) { - patch.priority = Math.max(0, Math.floor(patch.priority)); - } - if (patch.isSavings !== undefined || patch.percent !== undefined) { - const current = cats.find((c) => c.id === id); - if (!current) return; - const nextPercent = patch.percent ?? current.percent; - const wasSavings = current.isSavings ? current.percent : 0; - const nextIsSavings = patch.isSavings ?? current.isSavings; - const nextSavings = nextIsSavings ? nextPercent : 0; - const nextTotal = - total - current.percent + (patch.percent ?? current.percent); - const nextSavingsTotal = - savingsTotal - wasSavings + nextSavings; - if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) { - push( - "err", - `Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)` - ); - return; - } - } - updateM.mutate( - { id, body: patch }, - { - onError: (err: any) => - push("err", err?.message ?? "Update failed"), - }, - ); - }; - - const onDelete = (id: string) => { - deleteM.mutate(id, { - onSuccess: () => push("ok", "Expense deleted"), - onError: (err: any) => - push("err", err?.message ?? "Delete failed"), - }); - }; - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - setOrder((prev) => { - const oldIndex = prev.indexOf(String(active.id)); - const newIndex = prev.indexOf(String(over.id)); - const next = arrayMove(prev, oldIndex, newIndex); - // Apply new priorities to server (only changed ones) - const updates = onDragOrderApply(next); - updates.forEach(({ id, priority }) => { - const existing = cats.find((c) => c.id === id); - if (existing && existing.priority !== priority) { - updateM.mutate({ id, body: { priority } }); - } - }); - return next; - }); - }; - - if (isLoading) - return ( -
- -
Loading…
-
- ); - if (error || !data) { - return ( -
- -

Couldn't load expenses.

- -
- ); - } - - return ( -
-
- - -
-

Expenses

-

- Decide how every dollar is divided. Percentages must always - add up to 100%. -

-
- - {/* Add form */} -
- setName(e.target.value)} - /> - setPercent(e.target.value)} - /> - setPriority(e.target.value)} - /> - - -
- -
-
- - {cats.length === 0 ? ( -
- No expenses yet. -
- ) : ( - - - - - - - - - - - - - - - - {order - .map((id) => cats.find((c) => c.id === id)) - .filter(Boolean) - .map((c) => ( - - - - - - - - - - ))} - - -
Name%PrioritySavingsBalance
- - ⋮⋮ - - - - onEdit(c!.id, { name: v }) - } - /> - - - onEdit(c!.id, { percent: v }) - } - /> - - - onEdit(c!.id, { priority: v }) - } - /> - - - onEdit(c!.id, { isSavings: v }) - } - /> - {c!.isSavings && ( - - Savings - - )} - - - - -
-
- )} - - {/* Guard if total != 100 */} - {total !== 100 && ( -
- Percents must sum to 100% for allocations. - Current total: {total}%. -
- )} -
-
- ); -} - -/* --- tiny inline editors --- */ -function InlineEditText({ - value, - onChange, -}: { - value: string; - onChange: (v: string) => void; -}) { - const [v, setV] = useState(value); - const [editing, setEditing] = useState(false); - useEffect(() => setV(value), [value]); - const commit = () => { - if (v !== value) onChange(v.trim()); - setEditing(false); - }; - return editing ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -function InlineEditNumber({ - value, - onChange, - min = 0, - max = Number.MAX_SAFE_INTEGER, -}: { - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; -}) { - const [editing, setEditing] = useState(false); - const [v, setV] = useState(String(value)); - useEffect(() => setV(String(value)), [value]); - const commit = () => { - const n = Math.max( - min, - Math.min(max, Math.floor(Number(v) || 0)), - ); - if (n !== value) onChange(n); - setEditing(false); - }; - return editing ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -function InlineEditCheckbox({ - checked, - onChange, -}: { - checked: boolean; - onChange: (v: boolean) => void; -}) { - return ( - - ); -} - -function SortableTr({ - id, - children, -}: { - id: string; - children: React.ReactNode; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - } = useSortable({ id }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - } as React.CSSProperties; - return ( - - {children} - - ); -} - -function onDragOrderApply(ids: string[]) { - return ids.map((id, idx) => ({ id, priority: idx + 1 })); -} diff --git a/web/src/pages/settings/PlansPage.tsx b/web/src/pages/settings/PlansPage.tsx deleted file mode 100644 index 939a070..0000000 --- a/web/src/pages/settings/PlansPage.tsx +++ /dev/null @@ -1,747 +0,0 @@ -// web/src/pages/settings/PlansPage.tsx -import { - useMemo, - useState, - useEffect, - type FormEvent, - type ReactNode, -} from "react"; -import { useDashboard } from "../../hooks/useDashboard"; -import SettingsNav from "./_SettingsNav"; -import { - useCreatePlan, - useUpdatePlan, - useDeletePlan, -} from "../../hooks/useFixedPlans"; -import { Money } from "../../components/ui"; -import { useToast } from "../../components/Toast"; -import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone"; - -function daysUntil(iso: string, userTimezone: string) { - const today = getTodayInTimezone(userTimezone); - const due = isoToDateString(iso, userTimezone); - const todayDate = new Date(today); - const dueDate = new Date(due); - const diffMs = dueDate.getTime() - todayDate.getTime(); - return Math.round(diffMs / (24 * 60 * 60 * 1000)); -} - -function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) { - const d = daysUntil(dueISO, userTimezone); - if (d < 0) - return ( - - Overdue - - ); - if (d <= 7) return Due in {d}d; - return ( - - ); -} - -export default function SettingsPlansPage() { - const { data, isLoading, error, refetch, isFetching } = useDashboard(); - const createM = useCreatePlan(); - const updateM = useUpdatePlan(); - const deleteM = useDeletePlan(); - const { push } = useToast(); - - // Get user timezone from dashboard data - const userTimezone = data?.user?.timezone || getBrowserTimezone(); - - // Add form state - const [name, setName] = useState(""); - const [total, setTotal] = useState(""); - const [funded, setFunded] = useState(""); - const [priority, setPriority] = useState(""); - const [due, setDue] = useState(getTodayInTimezone(userTimezone)); - - // Auto-payment form state - const [autoPayEnabled, setAutoPayEnabled] = useState(false); - const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly"); - const [dayOfMonth, setDayOfMonth] = useState(1); - const [dayOfWeek, setDayOfWeek] = useState(0); - const [everyNDays, setEveryNDays] = useState(30); - const [minFundingPercent, setMinFundingPercent] = useState(100); - - const totals = useMemo(() => { - if (!data) return { funded: 0, total: 0, remaining: 0 }; - const funded = data.fixedPlans.reduce( - (s, p) => s + p.fundedCents, - 0, - ); - const total = data.fixedPlans.reduce( - (s, p) => s + p.totalCents, - 0, - ); - return { - funded, - total, - remaining: Math.max(0, total - funded), - }; - }, [data]); - - const overallPctFunded = useMemo(() => { - if (!totals.total) return 0; - return Math.round((totals.funded / totals.total) * 100); - }, [totals.funded, totals.total]); - - if (isLoading) - return ( -
- -
Loading…
-
- ); - - if (error || !data) { - return ( -
- -

Couldn't load fixed expenses.

- -
- ); - } - - const onAdd = (e: FormEvent) => { - e.preventDefault(); - const totalCents = Math.max( - 0, - Math.round((parseFloat(total || "0")) * 100), - ); - const fundedCents = Math.max( - 0, - Math.round((parseFloat(funded || "0")) * 100), - ); - const paymentSchedule = autoPayEnabled ? { - frequency, - ...(frequency === "monthly" ? { dayOfMonth } : {}), - ...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}), - ...(frequency === "custom" ? { everyNDays } : {}), - minFundingPercent, - } : undefined; - - const body = { - name: name.trim(), - totalCents, - fundedCents: Math.min(fundedCents, totalCents), - priority: Math.max( - 0, - Math.floor(Number(priority) || 0), - ), - dueOn: dateStringToUTCMidnight(due, userTimezone), - autoPayEnabled, - paymentSchedule, - }; - if (!body.name || totalCents <= 0) return; - createM.mutate(body, { - onSuccess: () => { - push("ok", "Plan created"); - setName(""); - setTotal(""); - setFunded(""); - setPriority(""); - setDue(getTodayInTimezone(userTimezone)); - setAutoPayEnabled(false); - setFrequency("monthly"); - setDayOfMonth(1); - setDayOfWeek(0); - setEveryNDays(30); - setMinFundingPercent(100); - }, - onError: (err: any) => - push("err", err?.message ?? "Create failed"), - }); - }; - - const onEdit = ( - id: string, - patch: Partial<{ - name: string; - totalCents: number; - fundedCents: number; - priority: number; - dueOn: string; - }>, - ) => { - if ( - "totalCents" in patch && - "fundedCents" in patch && - (patch.totalCents ?? 0) < (patch.fundedCents ?? 0) - ) { - patch.fundedCents = patch.totalCents; - } - updateM.mutate( - { id, body: patch }, - { - onSuccess: () => push("ok", "Plan updated"), - onError: (err: any) => - push("err", err?.message ?? "Update failed"), - }, - ); - }; - - const onDelete = (id: string) => { - deleteM.mutate(id, { - onSuccess: () => push("ok", "Plan deleted"), - onError: (err: any) => - push("err", err?.message ?? "Delete failed"), - }); - }; - - const addDisabled = - !name || !total || createM.isPending; - - return ( -
-
- - - {/* Header */} -
-

- Fixed expenses -

-

- Long-term goals and obligations you’re funding over - time. -

-
- - {/* KPI strip */} -
- - - - - - - - - - - - {overallPctFunded}% - - -
- - {/* Overall progress bar */} -
-
- All fixed expenses funded - - {overallPctFunded}% of target - -
-
-
-
-
- - {/* Add form */} -
- setName(e.target.value)} - /> - setTotal(e.target.value)} - /> - setFunded(e.target.value)} - /> - setPriority(e.target.value)} - /> - setDue(e.target.value)} - /> - - -
- - {/* Auto-payment configuration */} - {autoPayEnabled && ( -
-

Auto-Fund Schedule

-
- - - {(frequency === "weekly" || frequency === "biweekly") && ( - - )} - - {frequency === "monthly" && ( - - )} - - {frequency === "custom" && ( - - )} - - -
-

- Automatic payments will only occur if the expense is funded to at least the minimum percentage. -

-
- )} - - {/* Table */} - {data.fixedPlans.length === 0 ? ( -
- No fixed expenses yet. -
- ) : ( - - - - - - - - - - - - - - - - {data.fixedPlans - .slice() - .sort( - (a, b) => - a.priority - b.priority || - new Date(a.dueOn).getTime() - - new Date(b.dueOn).getTime(), - ) - .map((p) => { - const remaining = Math.max( - 0, - p.totalCents - p.fundedCents, - ); - const pctFunded = p.totalCents - ? Math.round( - (p.fundedCents / p.totalCents) * 100, - ) - : 0; - return ( - - - - - - - - - - - - ); - })} - -
NameDuePriorityFundedTotalRemainingStatusAuto-Pay
- - onEdit(p.id, { name: v }) - } - /> - - - onEdit(p.id, { dueOn: iso }) - } - /> - - - onEdit(p.id, { priority: n }) - } - /> - -
- - onEdit(p.id, { - fundedCents: Math.max( - 0, - Math.min(cents, p.totalCents), - ), - }) - } - /> -
- {pctFunded}% funded -
-
-
-
- - onEdit(p.id, { - totalCents: Math.max(cents, 0), - fundedCents: Math.min( - p.fundedCents, - cents, - ), - }) - } - /> - -
-
- - - - -
- - {p.autoPayEnabled ? 'Enabled' : 'Disabled'} - - {p.autoPayEnabled && p.paymentSchedule && ( - - {p.paymentSchedule.frequency === 'custom' - ? `Every ${p.paymentSchedule.customDays} days` - : p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1) - } - - )} -
-
- -
- )} -
-
- ); -} - -/* --- Small presentational helpers --- */ - -function KpiCard({ - label, - children, -}: { - label: string; - children: ReactNode; -}) { - return ( -
-

{label}

-
{children}
-
- ); -} - -function FundingBar({ pct }: { pct: number }) { - const clamped = Math.min(100, Math.max(0, pct)); - return ( -
-
-
- ); -} - -/* --- Inline editors (same behavior, slightly nicer UX) --- */ - -function InlineEditText({ - value, - onChange, -}: { - value: string; - onChange: (v: string) => void; -}) { - const [editing, setEditing] = useState(false); - const [v, setV] = useState(value); - useEffect(() => setV(value), [value]); - const commit = () => { - const t = v.trim(); - if (t && t !== value) onChange(t); - setEditing(false); - }; - return editing ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -function InlineEditNumber({ - value, - onChange, - min = 0, - max = Number.MAX_SAFE_INTEGER, -}: { - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; -}) { - const [editing, setEditing] = useState(false); - const [v, setV] = useState(String(value)); - useEffect(() => setV(String(value)), [value]); - const commit = () => { - const n = Math.max( - min, - Math.min(max, Math.floor(Number(v) || 0)), - ); - if (n !== value) onChange(n); - setEditing(false); - }; - return editing ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -function InlineEditMoney({ - valueCents, - onChange, -}: { - valueCents: number; - onChange: (cents: number) => void; -}) { - const [editing, setEditing] = useState(false); - const [v, setV] = useState((valueCents / 100).toFixed(2)); - useEffect( - () => setV((valueCents / 100).toFixed(2)), - [valueCents], - ); - const commit = () => { - const cents = Math.max( - 0, - Math.round((parseFloat(v || "0")) * 100), - ); - if (cents !== valueCents) onChange(cents); - setEditing(false); - }; - return editing ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -function InlineEditDate({ - value, - onChange, - timezone, -}: { - value: string; - onChange: (iso: string) => void; - timezone: string; -}) { - const [editing, setEditing] = useState(false); - const [v, setV] = useState( - isoToDateString(value, timezone), - ); - useEffect( - () => - setV( - isoToDateString(value, timezone), - ), - [value, timezone], - ); - const commit = () => { - const iso = dateStringToUTCMidnight(v, timezone); - if (iso !== value) onChange(iso); - setEditing(false); - }; - return editing ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} diff --git a/web/src/pages/settings/_SettingsNav.tsx b/web/src/pages/settings/_SettingsNav.tsx deleted file mode 100644 index 27c0343..0000000 --- a/web/src/pages/settings/_SettingsNav.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { NavLink } from "react-router-dom"; -export default function SettingsNav() { - const link = (to: string, label: string) => - `link ${isActive ? "link-active" : ""}`}>{label}; - return ( -
-

Settings

-
- {link("/settings/categories", "Expenses")} - {link("/settings/plans", "Fixed Expenses")} -
-
- ); -} diff --git a/web/src/styles.css.bak b/web/src/styles.css.bak deleted file mode 100644 index 1086306..0000000 --- a/web/src/styles.css.bak +++ /dev/null @@ -1,132 +0,0 @@ -:root { - --color-bg: #0b0c10; - --color-panel: #111318; - --color-fg: #e7e9ee; - --color-ink: #2a2e37; - --color-accent: #5dd6b2; - --radius-xl: 12px; - --radius-lg: 10px; - --radius-md: 8px; - --shadow-1: 0 6px 20px rgba(0,0,0,0.25); -} - -* { box-sizing: border-box; } -html, body, #root { height: 100%; } -body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } -a { color: inherit; text-decoration: none; } - -.container { width: min(1100px, 100vww); margin-inline: auto; padding: 0 16px; } -.muted { opacity: 0.7; } - -.card { - background: var(--color-panel); - border: 1px solid var(--color-ink); - border-radius: var(--radius-xl); - padding: 16px; - box-shadow: var(--shadow-1); -} - -.row { display: flex; align-items: center; } -.stack { display: grid; gap: 12px; } - -.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; } - -.input { - background: #0f1116; - color: var(--color-fg); - border: 1px solid var(--color-ink); - border-radius: var(--radius-lg); - padding: 8px 10px; - outline: none; -} -.input:focus { border-color: var(--color-accent); } - -.btn { - background: var(--color-accent); - color: #062016; - border: none; - border-radius: var(--radius-lg); - padding: 8px 12px; - font-weight: 700; - cursor: pointer; -} -.btn[disabled] { opacity: 0.5; cursor: default; } -.badge { - background: var(--color-ink); - border-radius: var(--radius-lg); - padding: 4px 8px; - font-size: 12px; -} - -.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; } -.table thead th { - text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px; -} -.table tbody tr { - background: var(--color-panel); - border: 1px solid var(--color-ink); - border-radius: var(--radius-xl); -} -.table td { padding: 8px; } - -.toast-err { - background: #b21d2a; - color: white; - border-radius: var(--radius-lg); - padding: 10px 12px; -} - -.border { border: 1px solid var(--color-ink); } -.rounded-xl { border-radius: var(--radius-xl); } -.divide-y > * + * { border-top: 1px solid var(--color-ink); } - -/* utility-ish */ -.w-44 { width: 11rem; } -.w-56 { width: 14rem; } -.w-40 { width: 10rem; } -.ml-auto { margin-left: auto; } -.mt-2 { margin-top: 0.5rem; } -.mt-3 { margin-top: 0.75rem; } -.mt-4 { margin-top: 1rem; } -.mb-3 { margin-bottom: 0.75rem; } -.gap-2 { gap: 0.5rem; } -.gap-4 { gap: 1rem; } -.flex-wrap { flex-wrap: wrap; } -.text-sm { font-size: 0.875rem; } -.text-xl { font-size: 1.25rem; } -.font-semibold { font-weight: 600; } -.font-bold { font-weight: 700; } -.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } -.bg-\[--color-ink\] { background: var(--color-ink); } -.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); } -.bg-\[--color-panel\] { background: var(--color-panel); } -.text-\[--color-fg\] { color: var(--color-fg); } -.border-\[--color-ink\] { border-color: var(--color-ink); } -.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); } -.p-2 { padding: 0.5rem; } -.p-3 { padding: 0.75rem; } -.p-4 { padding: 1rem; } -.p-6 { padding: 1.5rem; } -.py-8 { padding-block: 2rem; } -.h-14 { height: 3.5rem; } -.text-center { text-align: center; } -.text-right { text-align: right; } -.opacity-70 { opacity: .7; } -.grid { display: grid; } -.md\:grid-cols-2 { grid-template-columns: 1fr; } -.md\:grid-cols-3 { grid-template-columns: 1fr; } -@media (min-width: 768px) { - .md\:grid-cols-2 { grid-template-columns: 1fr 1fr; } - .md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; } -} -.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); } -.underline { text-decoration: underline; } -.fixed { position: fixed; } -.bottom-4 { bottom: 1rem; } -.left-1\/2 { left: 50%; } -.-translate-x-1\/2 { transform: translateX(-50%); } -.z-50 { z-index: 50; } -.space-y-2 > * + * { margin-top: .5rem; } -.space-y-8 > * + * { margin-top: 2rem; } -.space-y-6 > * + * { margin-top: 1.5rem; } -.overflow-x-auto { overflow-x: auto; }