Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion src/app/api/owner-expenses/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,30 @@ import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn(),
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
externalUsageEvent: {
findMany: mocks.findMany,
create: mocks.create,
deleteMany: mocks.deleteMany,
},
},
}));

let GET: typeof import("../route").GET;
let POST: typeof import("../route").POST;
let DELETE: typeof import("../route").DELETE;
let createSessionToken: typeof import("@/lib/auth").createSessionToken;

const READ_TOKEN = "r".repeat(64);

beforeAll(async () => {
process.env.SESSION_SECRET = "s".repeat(64);
({ GET, POST } = await import("../route"));
({ GET, POST, DELETE } = await import("../route"));
({ createSessionToken } = await import("@/lib/auth"));
});

beforeEach(() => {
Expand All @@ -32,6 +37,8 @@ beforeEach(() => {
delete process.env.OWNER_EXPENSE_TOKEN;
mocks.findMany.mockReset();
mocks.findMany.mockResolvedValue([]);
mocks.deleteMany.mockReset();
mocks.deleteMany.mockResolvedValue({ count: 0 });
});

function getRequest(
Expand Down Expand Up @@ -155,3 +162,107 @@ describe("GET /api/owner-expenses", () => {
}
});
});

describe("DELETE /api/owner-expenses", () => {
const KEY_A = `owner-recorded-expense:v1:${"a".repeat(64)}`;
const KEY_B = `owner-recorded-expense:v1:${"b".repeat(64)}`;

function deleteRequest(
keys: unknown,
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.

​

​

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.

​

​

method: "DELETE",
headers: {
"content-type": "application/json",
cookie: `dashboard_session=${token}`,
...headers,
},
body: JSON.stringify({ idempotencyKeys: keys }),
});
}

it("401s without a session cookie", async () => {
const request = new NextRequest(
"https://usage.jays.services/api/owner-expenses",
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idempotencyKeys: [KEY_A] }),
}
);
const response = await DELETE(request);
expect(response.status).toBe(401);
expect(mocks.deleteMany).not.toHaveBeenCalled();
});

it("401s with an owner expense token but no session (no token fallback)", async () => {
process.env.OWNER_EXPENSE_TOKEN = "t".repeat(64);
const request = new NextRequest(
"https://usage.jays.services/api/owner-expenses",
{
method: "DELETE",
headers: {
"content-type": "application/json",
"x-owner-expense-token": "t".repeat(64),
},
body: JSON.stringify({ idempotencyKeys: [KEY_A] }),
}
);
const response = await DELETE(request);
expect(response.status).toBe(401);
expect(mocks.deleteMany).not.toHaveBeenCalled();
});

it("403s a cross-site cookie request (CSRF guard)", async () => {
const response = await DELETE(
deleteRequest([KEY_A], { "sec-fetch-site": "cross-site" })
);
expect(response.status).toBe(403);
expect(mocks.deleteMany).not.toHaveBeenCalled();
});

it("400s on a malformed idempotency key", async () => {
const response = await DELETE(deleteRequest(["not-a-key"]));
expect(response.status).toBe(400);
expect(mocks.deleteMany).not.toHaveBeenCalled();
});

it("400s on an empty key list", async () => {
const response = await DELETE(deleteRequest([]));
expect(response.status).toBe(400);
expect(mocks.deleteMany).not.toHaveBeenCalled();
});

it("400s on a non-expense key shape", async () => {
const response = await DELETE(deleteRequest(["usage-telemetry:v1:abc"]));
expect(response.status).toBe(400);
expect(mocks.deleteMany).not.toHaveBeenCalled();
});

it("deletes scoped to owner-recorded expenses and reports notFound", async () => {
mocks.findMany.mockResolvedValue([{ idempotencyKey: KEY_A }]);
mocks.deleteMany.mockResolvedValue({ count: 1 });
const response = await DELETE(deleteRequest([KEY_A, KEY_B]));
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({
requested: 2,
deleted: 1,
notFound: [KEY_B],
});
expect(mocks.deleteMany).toHaveBeenCalledTimes(1);
const where = mocks.deleteMany.mock.calls[0][0].where;
expect(where.sourceApp).toBe("owner-recorded-expense");
expect(where.idempotencyKey).toEqual({ in: [KEY_A, KEY_B] });
});

it("dedupes repeated keys", async () => {
mocks.findMany.mockResolvedValue([{ idempotencyKey: KEY_A }]);
mocks.deleteMany.mockResolvedValue({ count: 1 });
const response = await DELETE(deleteRequest([KEY_A, KEY_A]));
const body = await response.json();
expect(body.requested).toBe(1);
});
});
89 changes: 88 additions & 1 deletion src/app/api/owner-expenses/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { hasValidDashboardSession, shouldEnforceDashboardSession } from "@/lib/auth";
import {
hasValidDashboardSession,
isCsrfSafeRequest,
shouldEnforceDashboardSession,
} from "@/lib/auth";
import { readBoundedJsonBody } from "@/lib/bounded-request-body";
import {
isUsageReadAuthorized,
Expand Down Expand Up @@ -150,3 +154,86 @@ export async function GET(request: NextRequest) {
hasMore,
});
}

const DELETE_MAX_KEYS = 100;
const OWNER_EXPENSE_KEY_RE = /^owner-recorded-expense:v1:[0-9a-f]{64}$/;

/**
* DELETE /api/owner-expenses
*
* Admin-only removal of owner-recorded expense rows by idempotency key.
* Dashboard session cookie required — there is intentionally no
* OWNER_EXPENSE_TOKEN fallback; deleting ledger rows is destructive and
* stays behind the admin session. The CSRF guard applies because this is
* a cookie-authenticated mutator.
*
* Two independent 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".
*
* Body: { "idempotencyKeys": ["owner-recorded-expense:v1:<64hex>", ...] }
* Response: { requested, deleted, notFound: [...] }
*/
export async function DELETE(request: NextRequest) {
if (!hasValidDashboardSession(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isCsrfSafeRequest(request)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

let body: unknown;
try {
body = await readBoundedJsonBody(request, {
label: "Owner expense delete body",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid request";
return NextResponse.json({ error: message }, { status: 400 });
}

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.

​

​

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.

​

​

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.

​

​

: undefined;
if (
!Array.isArray(rawKeys) ||
rawKeys.length === 0 ||
rawKeys.length > DELETE_MAX_KEYS ||
rawKeys.some(
(key) => typeof key !== "string" || !OWNER_EXPENSE_KEY_RE.test(key)
)
) {
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(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.

​

​

where: {
sourceApp: OWNER_EXPENSE_SOURCE_APP,
idempotencyKey: { in: keys },
},
select: { idempotencyKey: true },
});
const existingKeys = new Set(existing.map((row) => row.idempotencyKey));

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)),
});
Comment on lines +227 to +238

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.

​

​

}
Loading