Skip to content

Add admin-only DELETE /api/owner-expenses by idempotency key - #1609

Merged
jaywedgeworth22 merged 1 commit into
mainfrom
Muse-Assist/owner-expense-delete
Oct 6, 2026
Merged

jaywedgeworth22 merged 1 commit into
mainfrom
Muse-Assist/owner-expense-delete

Conversation

@jaywedgeworth22

@jaywedgeworth22 jaywedgeworth22 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds an admin-only DELETE /api/owner-expenses endpoint that removes owner-recorded expense rows by idempotency key. Deletion is destructive, so it is gated behind the dashboard session cookie only — there is intentionally no OWNER_EXPENSE_TOKEN fallback — and is protected by the CSRF guard since it is a cookie-authenticated mutator.

Changes

src/app/api/owner-expenses/route.ts

  • New DELETE handler on the owner-expenses route.
  • Authorization: requires a valid dashboard session; returns 401 otherwise. Requests presenting only an x-owner-expense-token are rejected. Cross-site cookie requests are rejected with 403 via the CSRF check.
  • Request body: { "idempotencyKeys": ["owner-recorded-expense:v1:<64 hex>", ...] }, read through the bounded JSON body helper.
  • Validation returns 400 when the key list is missing, not an array, empty, longer than 100 entries, or contains any key that does not match the owner-expense idempotency format. Duplicate keys are collapsed before processing.
  • Delete scope is pinned to sourceApp: "owner-recorded-expense" in the where clause, so a valid-shaped key can never remove a non-expense row.
  • Response: { requested, deleted, notFound: [...] }, where notFound lists requested keys that did not exist.

src/app/api/owner-expenses/__tests__/route.test.ts

  • Adds a deleteMany mock and imports the DELETE export and createSessionToken.
  • New DELETE /api/owner-expenses suite covering: 401 without a session cookie, 401 with an owner-expense token but no session, 403 for a cross-site cookie request, 400 for malformed keys, an empty key list, and a non-expense key shape (each asserting no delete is attempted); plus scoping assertions on the sourceApp / idempotencyKey filter, reporting of notFound, and deduplication of repeated keys.

Review Findings

The automated review raised 7 findings (2 critical, 5 high) that are not addressed by the code in this PR:

  • Critical — the test file commits a real production hostname instead of using a synthetic test origin (2 findings, same location).
  • High — the DELETE handler reaches into the request body with a type assertion rather than parsing it with Zod before it reaches Prisma (3 findings, same location).
  • High — deletions remove rows that feed the cached budget-status aggregate but never call bustBudgetStatusCache(), leaving budget figures stale after a purge.
  • High — notFound is derived from a pre-delete read, so a row created between the findMany and deleteMany would be deleted yet reported as not found.

Adds DELETE /api/owner-expenses for removing duplicate owner-recorded
expense rows from the ledger.  Dashboard session cookie required with no
token fallback, plus the cookie-mutator CSRF guard.  Two safety pins keep a
key from ever deleting a non-expense row: the key format is validated
against the owner-expense idempotency shape, and the delete WHERE clause is
pinned to sourceApp owner-recorded-expense.  Returns requested/deleted/
notFound counts.  Needed to purge 11 double-posted receipt rows inflating
budget math by $1,553.86.
@jaywedgeworth22
jaywedgeworth22 enabled auto-merge (squash) October 6, 2026 07:04
@kody-ai

kody-ai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ✅

Access your configuration settings here.

​

@jaywedgeworth22
jaywedgeworth22 merged commit fa08f7b into main Oct 6, 2026
11 checks passed
@jaywedgeworth22
jaywedgeworth22 deleted the Muse-Assist/owner-expense-delete branch October 6, 2026 07:07
headers: Record<string, string> = {}
): NextRequest {
const token = createSessionToken();
return new NextRequest("https://usage.jays.services/api/owner-expenses", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules critical

Production hostname exposure in src/app/api/owner-expenses/__tests__/route.test.ts: the test fixture commits the private production infrastructure origin https://usage.jays.services in public source, even though the value only constructs a request object. Use the synthetic non-production origin https://owner-expenses.test.

Also found in:

  • src/app/api/owner-expenses/__tests__/route.test.ts:188-188
  • src/app/api/owner-expenses/__tests__/route.test.ts:203-203

Kody rule violation: Keep credentials out of public source and verify UI changes with automated screenshots

Prompt for LLM

File src/app/api/owner-expenses/__tests__/route.test.ts:

Line 175:

Production hostname exposure in `src/app/api/owner-expenses/__tests__/route.test.ts`: the test fixture commits the private production infrastructure origin `https://usage.jays.services` in public source, even though the value only constructs a request object. Use the synthetic non-production origin `https://owner-expenses.test`.

**Also found in:**
- `src/app/api/owner-expenses/__tests__/route.test.ts:188-188`
- `src/app/api/owner-expenses/__tests__/route.test.ts:203-203`

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

headers: Record<string, string> = {}
): NextRequest {
const token = createSessionToken();
return new NextRequest("https://usage.jays.services/api/owner-expenses", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules critical

Production hostname exposure in src/app/api/owner-expenses/__tests__/route.test.ts: the test fixture commits the private production infrastructure origin https://usage.jays.services in public source, even though the value only constructs a request object. Use the synthetic non-production origin https://owner-expenses.test.

Also found in:

  • src/app/api/owner-expenses/__tests__/route.test.ts:188-188
  • src/app/api/owner-expenses/__tests__/route.test.ts:203-203

Kody rule violation: Never expose secrets or private infrastructure values; reuse existing fleet env vars

Prompt for LLM

File src/app/api/owner-expenses/__tests__/route.test.ts:

Line 175:

Production hostname exposure in `src/app/api/owner-expenses/__tests__/route.test.ts`: the test fixture commits the private production infrastructure origin `https://usage.jays.services` in public source, even though the value only constructs a request object. Use the synthetic non-production origin `https://owner-expenses.test`.

**Also found in:**
- `src/app/api/owner-expenses/__tests__/route.test.ts:188-188`
- `src/app/api/owner-expenses/__tests__/route.test.ts:203-203`

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


const rawKeys =
body && typeof body === "object"
? (body as Record<string, unknown>).idempotencyKeys

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Runtime validation gap in src/app/api/owner-expenses/route.ts: the HTTP-boundary assertion (body as Record<string, unknown>).idempotencyKeys trusts untyped input before DELETE uses it for deletion. Add import { z } from "zod";, define a strict OwnerExpenseDeleteSchema before DELETE, replace the extraction and manual guard with safeParse, and use only parsed.data.idempotencyKeys for deletion.

Also found in:

  • src/app/api/owner-expenses/route.ts:216-216

Kody rule violation: Validate every inbound payload with Zod before it reaches Prisma

import { z } from "zod";

const OwnerExpenseDeleteSchema = z
  .object({
    idempotencyKeys: z
      .array(z.string().regex(OWNER_EXPENSE_KEY_RE))
      .min(1)
      .max(DELETE_MAX_KEYS),
  })
  .strict();

const parsed = OwnerExpenseDeleteSchema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    {
      error:
        "idempotencyKeys must be a non-empty array of at most 100 owner-recorded-expense idempotency keys",
    },
    { status: 400 },
  );
}
const keys = [...new Set(parsed.data.idempotencyKeys)];
Prompt for LLM

File src/app/api/owner-expenses/route.ts:

Line 198:

Runtime validation gap in `src/app/api/owner-expenses/route.ts`: the HTTP-boundary assertion `(body as Record<string, unknown>).idempotencyKeys` trusts untyped input before `DELETE` uses it for deletion. Add `import { z } from "zod";`, define a strict `OwnerExpenseDeleteSchema` before `DELETE`, replace the extraction and manual guard with `safeParse`, and use only `parsed.data.idempotencyKeys` for deletion.

**Also found in:**
- `src/app/api/owner-expenses/route.ts:216-216`

Suggested Code:

import { z } from "zod";

const OwnerExpenseDeleteSchema = z
  .object({
    idempotencyKeys: z
      .array(z.string().regex(OWNER_EXPENSE_KEY_RE))
      .min(1)
      .max(DELETE_MAX_KEYS),
  })
  .strict();

const parsed = OwnerExpenseDeleteSchema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    {
      error:
        "idempotencyKeys must be a non-empty array of at most 100 owner-recorded-expense idempotency keys",
    },
    { status: 400 },
  );
}
const keys = [...new Set(parsed.data.idempotencyKeys)];

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


const rawKeys =
body && typeof body === "object"
? (body as Record<string, unknown>).idempotencyKeys

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

HTTP-boundary type assertion in src/app/api/owner-expenses/route.ts: (body as Record<string, unknown>).idempotencyKeys derives the DELETE key-array type from an asserted object shape without runtime validation. Replace it with a strict Zod schema and safeParse, deriving the key-array type from the schema rather than trusting the asserted shape.

Also found in:

  • src/app/api/owner-expenses/route.ts:216-216

Kody rule violation: Validate all boundary data with Zod schemas instead of type assertions

import { z } from "zod";

const OwnerExpenseDeleteSchema = z
  .object({
    idempotencyKeys: z
      .array(z.string().regex(OWNER_EXPENSE_KEY_RE))
      .min(1)
      .max(DELETE_MAX_KEYS),
  })
  .strict();

const parsed = OwnerExpenseDeleteSchema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    {
      error:
        "idempotencyKeys must be a non-empty array of at most 100 owner-recorded-expense idempotency keys",
    },
    { status: 400 },
  );
}
const keys = [...new Set(parsed.data.idempotencyKeys)];
Prompt for LLM

File src/app/api/owner-expenses/route.ts:

Line 198:

HTTP-boundary type assertion in `src/app/api/owner-expenses/route.ts`: `(body as Record<string, unknown>).idempotencyKeys` derives the `DELETE` key-array type from an asserted object shape without runtime validation. Replace it with a strict Zod schema and `safeParse`, deriving the key-array type from the schema rather than trusting the asserted shape.

**Also found in:**
- `src/app/api/owner-expenses/route.ts:216-216`

Suggested Code:

import { z } from "zod";

const OwnerExpenseDeleteSchema = z
  .object({
    idempotencyKeys: z
      .array(z.string().regex(OWNER_EXPENSE_KEY_RE))
      .min(1)
      .max(DELETE_MAX_KEYS),
  })
  .strict();

const parsed = OwnerExpenseDeleteSchema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    {
      error:
        "idempotencyKeys must be a non-empty array of at most 100 owner-recorded-expense idempotency keys",
    },
    { status: 400 },
  );
}
const keys = [...new Set(parsed.data.idempotencyKeys)];

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


const rawKeys =
body && typeof body === "object"
? (body as Record<string, unknown>).idempotencyKeys

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

HTTP request validation gap in src/app/api/owner-expenses/route.ts: the handler accesses idempotencyKeys without validating the entire request body. Validate the full HTTP request body with a strict Zod schema, return a 4xx response on failure, and use only the parsed result downstream.

Also found in:

  • src/app/api/owner-expenses/route.ts:216-216

Kody rule violation: Validate every untrusted input with a zod schema at the trust boundary

import { z } from "zod";

const OwnerExpenseDeleteSchema = z
  .object({
    idempotencyKeys: z
      .array(z.string().regex(OWNER_EXPENSE_KEY_RE))
      .min(1)
      .max(DELETE_MAX_KEYS),
  })
  .strict();

const parsed = OwnerExpenseDeleteSchema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    {
      error:
        "idempotencyKeys must be a non-empty array of at most 100 owner-recorded-expense idempotency keys",
    },
    { status: 400 },
  );
}
const keys = [...new Set(parsed.data.idempotencyKeys)];
Prompt for LLM

File src/app/api/owner-expenses/route.ts:

Line 198:

HTTP request validation gap in `src/app/api/owner-expenses/route.ts`: the handler accesses `idempotencyKeys` without validating the entire request body. Validate the full HTTP request body with a strict Zod schema, return a 4xx response on failure, and use only the parsed result downstream.

**Also found in:**
- `src/app/api/owner-expenses/route.ts:216-216`

Suggested Code:

import { z } from "zod";

const OwnerExpenseDeleteSchema = z
  .object({
    idempotencyKeys: z
      .array(z.string().regex(OWNER_EXPENSE_KEY_RE))
      .min(1)
      .max(DELETE_MAX_KEYS),
  })
  .strict();

const parsed = OwnerExpenseDeleteSchema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    {
      error:
        "idempotencyKeys must be a non-empty array of at most 100 owner-recorded-expense idempotency keys",
    },
    { status: 400 },
  );
}
const keys = [...new Set(parsed.data.idempotencyKeys)];

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Comment on lines +227 to +238
const deleted = await prisma.externalUsageEvent.deleteMany({
where: {
sourceApp: OWNER_EXPENSE_SOURCE_APP,
idempotencyKey: { in: keys },
},
});

return NextResponse.json({
requested: keys.length,
deleted: deleted.count,
notFound: keys.filter((key) => !existingKeys.has(key)),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Cache invalidation omission in src/app/api/owner-expenses/route.ts: the DELETE handler removes ExternalUsageEvent rows without calling bustBudgetStatusCache(), so GET /api/providers can keep serving provider spend and budget totals that include deleted expenses until the memoized budgetStatusSwrCache SWR entry expires after 60 seconds. Owner-expense rows carry costUsd and occurredAt and feed the month-to-date computeBudgetStatusUncached aggregate through sumMonthToDateExternalCostByProvider (src/lib/budget-status.ts:892), which group-bys all rows without a sourceApp exclusion; import bustBudgetStatusCache from @/lib/budget-status and call it when deleted.count > 0 to match recordOwnerExpense → bustBudgetStatusCache() after the same-table write (src/lib/owner-expense.ts:177) and preserve the invalidation invariant used by projects, providers, budget-controls, workspace-copy, and ensure-fleet-projects.

const deleted = await prisma.externalUsageEvent.deleteMany({
    where: {
      sourceApp: OWNER_EXPENSE_SOURCE_APP,
      idempotencyKey: { in: keys },
    },
  });

  // Same invariant the insert path upholds (see lib/owner-expense.ts recordOwnerExpense):
  // owner-expense rows feed sumMonthToDateExternalCostByProvider, which is memoized by the
  // budget-status SWR cache, so a delete must invalidate it too.
  if (deleted.count > 0) bustBudgetStatusCache();

  return NextResponse.json({
    requested: keys.length,
    deleted: deleted.count,
    notFound: keys.filter((key) => !existingKeys.has(key)),
  });
Prompt for LLM

File src/app/api/owner-expenses/route.ts:

Line 227 to 238:

Cache invalidation omission in `src/app/api/owner-expenses/route.ts`: the `DELETE` handler removes `ExternalUsageEvent` rows without calling `bustBudgetStatusCache()`, so `GET /api/providers` can keep serving provider spend and budget totals that include deleted expenses until the memoized `budgetStatusSwrCache` SWR entry expires after 60 seconds. Owner-expense rows carry `costUsd` and `occurredAt` and feed the month-to-date `computeBudgetStatusUncached` aggregate through `sumMonthToDateExternalCostByProvider` (`src/lib/budget-status.ts:892`), which group-bys all rows without a `sourceApp` exclusion; import `bustBudgetStatusCache` from `@/lib/budget-status` and call it when `deleted.count > 0` to match `recordOwnerExpense` → `bustBudgetStatusCache()` after the same-table write (`src/lib/owner-expense.ts:177`) and preserve the invalidation invariant used by projects, providers, budget-controls, workspace-copy, and ensure-fleet-projects.

Suggested Code:

const deleted = await prisma.externalUsageEvent.deleteMany({
    where: {
      sourceApp: OWNER_EXPENSE_SOURCE_APP,
      idempotencyKey: { in: keys },
    },
  });

  // Same invariant the insert path upholds (see lib/owner-expense.ts recordOwnerExpense):
  // owner-expense rows feed sumMonthToDateExternalCostByProvider, which is memoized by the
  // budget-status SWR cache, so a delete must invalidate it too.
  if (deleted.count > 0) bustBudgetStatusCache();

  return NextResponse.json({
    requested: keys.length,
    deleted: deleted.count,
    notFound: keys.filter((key) => !existingKeys.has(key)),
  });

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

}
const keys = [...new Set(rawKeys as string[])];

const existing = await prisma.externalUsageEvent.findMany({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Response inconsistency in src/app/api/owner-expenses/route.ts: notFound uses the pre-delete findMany snapshot at line 218 before deleteMany at line 227, so a same-key row inserted or replayed between the statements by a concurrent POST /api/owner-expenses from the iOS client, or by a second concurrent DELETE, can be removed while still reported in notFound; deleted.count can also exceed existing.size, leaving { requested, deleted, notFound } internally inconsistent and giving callers incorrect reconciliation data. Derive notFound from post-delete state or run both statements in a single prisma.$transaction instead of relying on the pre-delete read.

const deleted = await prisma.externalUsageEvent.deleteMany({
    where: {
      sourceApp: OWNER_EXPENSE_SOURCE_APP,
      idempotencyKey: { in: keys },
    },
  });

  // Derive notFound from post-delete state instead of a pre-delete snapshot, so a row
  // inserted between two reads can never be reported as not found while being deleted.
  const survivors = await prisma.externalUsageEvent.findMany({
    where: {
      sourceApp: OWNER_EXPENSE_SOURCE_APP,
      idempotencyKey: { in: keys },
    },
    select: { idempotencyKey: true },
  });
  const survivorKeys = new Set(survivors.map((row) => row.idempotencyKey));
Prompt for LLM

File src/app/api/owner-expenses/route.ts:

Line 218:

Response inconsistency in `src/app/api/owner-expenses/route.ts`: `notFound` uses the pre-delete `findMany` snapshot at line 218 before `deleteMany` at line 227, so a same-key row inserted or replayed between the statements by a concurrent `POST /api/owner-expenses` from the iOS client, or by a second concurrent `DELETE`, can be removed while still reported in `notFound`; `deleted.count` can also exceed `existing.size`, leaving `{ requested, deleted, notFound }` internally inconsistent and giving callers incorrect reconciliation data. Derive `notFound` from post-delete state or run both statements in a single `prisma.$transaction` instead of relying on the pre-delete read.

Suggested Code:

const deleted = await prisma.externalUsageEvent.deleteMany({
    where: {
      sourceApp: OWNER_EXPENSE_SOURCE_APP,
      idempotencyKey: { in: keys },
    },
  });

  // Derive notFound from post-delete state instead of a pre-delete snapshot, so a row
  // inserted between two reads can never be reported as not found while being deleted.
  const survivors = await prisma.externalUsageEvent.findMany({
    where: {
      sourceApp: OWNER_EXPENSE_SOURCE_APP,
      idempotencyKey: { in: keys },
    },
    select: { idempotencyKey: true },
  });
  const survivorKeys = new Set(survivors.map((row) => row.idempotencyKey));

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant