From d2b8da78392748ef350ee3a06e275182e4194bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 18:06:32 +0100 Subject: [PATCH] refactor: keep the size, cast and error-code guards where they were The fixes above grew six files over 800 lines and three long functions, added casts in tests and one error code the catalogue did not list. The claimable profile check moves into its own module, the guardian count is reused by the revoke path, the lifecycle fallback lives beside the lifecycle rules, the key-needs list is its own component, the coach sweep wraps its filters with ownedByActiveAccount, comments are tightened, and the test fixtures are typed instead of cast. The enumeration guard reads a write keyed by one handed id (id: runId, id: job.data.jobId) as scoped, so those need no marker. account.deletion.no_guardian joins the catalogue; two baselines go down. --- ...account-deletion-enumeration-guard.test.ts | 4 +- .../baselines/source-file-lines.json | 2 +- .../baselines/source-function-lines.json | 2 +- .../admin/users/[id]/__tests__/route.test.ts | 20 +-- .../ingest/medication/__tests__/route.test.ts | 25 ++- src/app/api/ingest/medication/route.ts | 30 ++-- .../__tests__/database-dump-row.test.tsx | 8 +- src/components/admin/encryption-section.tsx | 150 ++++++++++-------- src/lib/__tests__/idempotency.test.ts | 31 +++- src/lib/idempotency.ts | 22 +-- .../__tests__/pending-deletion-gate.test.ts | 51 ++++-- src/lib/jobs/__tests__/restore-drill.test.ts | 14 +- src/lib/jobs/coach-reminder-sweep.ts | 20 +-- src/lib/jobs/data-arrival.ts | 1 - src/lib/jobs/document-ai-run.ts | 1 - src/lib/jobs/intake-auto-take.ts | 1 - src/lib/jobs/medication-intake-import.ts | 1 - src/lib/jobs/offhost-backup.ts | 35 +--- src/lib/jobs/offhost-lifecycle.ts | 27 ++++ src/lib/jobs/restore-drill-outcome.ts | 10 +- src/lib/managed-profiles/claimable.ts | 21 +++ src/lib/managed-profiles/handover.ts | 43 ++--- src/lib/managed-profiles/lifecycle.ts | 13 +- src/lib/medications/intake-slot-dedup.ts | 1 - src/lib/openapi/error-codes.ts | 2 +- src/lib/rollups/measurement-rollups.ts | 24 ++- src/lib/sharing/grants.ts | 22 +-- 27 files changed, 302 insertions(+), 279 deletions(-) create mode 100644 src/lib/managed-profiles/claimable.ts diff --git a/src/__tests__/account-deletion-enumeration-guard.test.ts b/src/__tests__/account-deletion-enumeration-guard.test.ts index cf0bfd600..6dcdaf94a 100644 --- a/src/__tests__/account-deletion-enumeration-guard.test.ts +++ b/src/__tests__/account-deletion-enumeration-guard.test.ts @@ -92,8 +92,8 @@ const SCOPED = [ // (`userId: true`) or a negation that reads every other account. /\b(?:userId|recordUserId|subjectId|grantorId|profileId)\s*(?:[,}\n]|:(?!\s*(?:true|false)\b|\s*\{\s*(?:not|notIn)\b))/, /\bid\s*:\s*(?:userId|input\.userId|\{\s*in\b)/, - // `id: user.id`, `id: account.id`: the one account a loop is on. - /\bid\s*:\s*[\w$]+\.(?:id|userId)\b/, + // `id: user.id`, `id: runId`, `id: job.data.jobId`: one row it was handed. + /\bid\s*:\s*(?:[\w$]+\.)*[\w$]*[iI]d\b(?!\s*:)/, /"?user_id"?\s*=\s*(?:\$\{|ANY\s*\(\s*\$\{)/, /\bu\."id"\s*=\s*\$\{/, /"id"\s*=\s*(?:\$\{|ANY\s*\(\s*\$\{)/, diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index a34432d80..66e675fd4 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -80,7 +80,7 @@ "lib/oura/sync.ts": 900, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, - "lib/sharing/grants.ts": 807, + "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, "lib/telegram-webhook-handlers.ts": 1341, "lib/validations/inbound-documents.ts": 1018, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 632c3c917..f865925fc 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -54,7 +54,7 @@ "components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212, "components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341, "components/admin/central-codex-section.tsx :: CentralCodexSection": 241, - "components/admin/encryption-section.tsx :: EncryptionSection": 303, + "components/admin/encryption-section.tsx :: EncryptionSection": 264, "components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220, "components/admin/glitchtip-section.tsx :: GlitchtipSection": 233, "components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232, diff --git a/src/app/api/admin/users/[id]/__tests__/route.test.ts b/src/app/api/admin/users/[id]/__tests__/route.test.ts index e5219f1be..9f4fe8c0b 100644 --- a/src/app/api/admin/users/[id]/__tests__/route.test.ts +++ b/src/app/api/admin/users/[id]/__tests__/route.test.ts @@ -124,23 +124,11 @@ describe("PUT /api/admin/users/[id]", () => { const body = (await res.json()) as { error: string }; expect(body.error).toMatch(/last admin/i); expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); - }); - - // An admin pending deletion cannot sign in and is erased when the window - // ends; counted, they let the last usable admin step down. - it("counts only the other admins who are not pending deletion, under the roster lock", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - id: "u1", - role: "ADMIN", - } as never); - vi.mocked(prisma.user.count).mockResolvedValue(0); - await PUT(jsonReq({ role: "USER" }), params("u1")); + // Only the other admins who are not pending deletion count (one pending + // deletion cannot sign in and is erased when the window ends), under the + // roster lock an admin's own deletion takes too. expect(vi.mocked(prisma.user.count).mock.calls[0]?.[0]).toEqual({ - where: { - role: "ADMIN", - id: { not: "u1" }, - deletionRequestedAt: null, - }, + where: { role: "ADMIN", id: { not: "u1" }, deletionRequestedAt: null }, }); expect(prisma.$queryRaw).toHaveBeenCalled(); }); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 0ab967c9d..4f1f66c5b 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -30,6 +30,9 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); +vi.mock("@/lib/account-deletion/pending", () => ({ + pendingDeletionOf: vi.fn(async () => null), +})); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), checkAuthSurfaceRateLimit: vi.fn(), @@ -67,6 +70,7 @@ import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { auditLog } from "@/lib/auth/audit"; +import { pendingDeletionOf } from "@/lib/account-deletion/pending"; function postReq( body: unknown, @@ -98,7 +102,6 @@ beforeEach(() => { revoked: false, expiresAt: null, lastUsedAt: null, - user: { deletionRequestedAt: null }, } as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); }); @@ -210,7 +213,6 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. revoked: false, expiresAt: null, lastUsedAt: null, - user: { deletionRequestedAt: null }, } as never); vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ @@ -375,8 +377,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. revoked: false, expiresAt: null, lastUsedAt: null, - user: { deletionRequestedAt: null }, - } as never); + } as Awaited>); } it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => { @@ -539,7 +540,6 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { revoked: false, expiresAt: null, lastUsedAt: null, - user: { deletionRequestedAt: null }, } as never) .mockResolvedValueOnce({ id: "tok-button", @@ -548,7 +548,6 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { revoked: false, expiresAt: null, lastUsedAt: null, - user: { deletionRequestedAt: null }, } as never); await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" })); @@ -608,19 +607,15 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { // The deletion revokes every token in its own transaction; one issued in // the same instant would slip past that, and is refused here as well. it("refuses the token of an account pending deletion", async () => { - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - id: "tok-1", - userId: "user-1", - permissions: ["*"], - revoked: false, - expiresAt: null, - lastUsedAt: null, - user: { deletionRequestedAt: new Date() }, - } as never); + vi.mocked(pendingDeletionOf).mockResolvedValueOnce({ + requestedAt: new Date(), + purgeAt: null, + }); const res = await POST( postReq({ medicationName: "Metformin", idempotencyKey: "k-pending-001" }), ); expect(res.status).toBe(401); + expect(pendingDeletionOf).toHaveBeenCalledWith("user-1"); }); it("falls back to the per-IP bucket when the token does not resolve", async () => { diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 7d0076d1b..31fd1ccdb 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; import { hashToken } from "@/lib/auth/hmac"; +import { pendingDeletionOf } from "@/lib/account-deletion/pending"; import { checkAuthSurfaceRateLimit, checkRateLimit, @@ -103,18 +104,12 @@ export const POST = apiHandler(async (request: NextRequest) => { revoked: true, expiresAt: true, lastUsedAt: true, - user: { select: { deletionRequestedAt: true } }, }, }); - // A deletion revokes the account's tokens in its own transaction; a token - // issued in the same instant would slip past that, so the pending account - // is refused here too, as `resolveBearerToken` does. - if ( - !apiToken || - apiToken.revoked || - apiToken.user.deletionRequestedAt !== null - ) { + // A pending account's token is refused, as `resolveBearerToken` does. + const pending = apiToken && (await pendingDeletionOf(apiToken.userId)); + if (!apiToken || apiToken.revoked || pending) { return refuseUnauthenticated(request, "Invalid or revoked token"); } @@ -122,17 +117,12 @@ export const POST = apiHandler(async (request: NextRequest) => { return refuseUnauthenticated(request, "Token expired"); } - // A bridge is identified by its TOKEN, not by the address it dials from. - // The bucket used to be `ingest:${ip}`, so every integration behind one - // NAT shared a single 60/min cell and one chatty bridge starved the rest — - // and a household running a chat bridge, a button and a home-automation - // rule is the normal deployment here, not an edge case. - // - // The identity is only known after the lookup, so the lookup now runs - // before the limiter: one indexed single-row read ahead of what was - // already an upsert against `rate_limits`. A caller whose token does not - // resolve is still capped on the per-IP bucket it was always capped on, - // one step earlier than it reaches anything expensive. + // A bridge is identified by its TOKEN, not by the address it dials from: + // keyed `ingest:${ip}`, every integration behind one NAT (a chat bridge, a + // button, a home-automation rule: the normal household) shared one 60/min + // cell and one chatty bridge starved the rest. So the lookup runs before + // the limiter, one indexed read ahead of the `rate_limits` upsert; a token + // that does not resolve is still capped on the per-IP bucket, earlier. const rl = await checkRateLimit( `ingest:token:${apiToken.id}`, RATE_LIMIT, diff --git a/src/components/admin/backups/__tests__/database-dump-row.test.tsx b/src/components/admin/backups/__tests__/database-dump-row.test.tsx index e980e821c..0d9c2c785 100644 --- a/src/components/admin/backups/__tests__/database-dump-row.test.tsx +++ b/src/components/admin/backups/__tests__/database-dump-row.test.tsx @@ -49,8 +49,12 @@ describe("", () => { it("names the newest good dump, where it is, and how long dumps keep deleted accounts", () => { const html = render(HEALTHY); expect(html).toContain("kept on this server and off-host"); - expect(html).toContain("the newest 7 nightly dumps are kept on this server"); - expect(html).toContain("before the latest update stays until the next update"); + expect(html).toContain( + "the newest 7 nightly dumps are kept on this server", + ); + expect(html).toContain( + "before the latest update stays until the next update", + ); expect(html).toContain("removed after 30 days"); expect(html).not.toContain('data-slot="database-dump-failed"'); expect(html).not.toContain('data-slot="database-dump-setup"'); diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index ebf362cb6..79e56c541 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -279,76 +279,7 @@ export function EncryptionSection() { description={t("admin.section.encryption.backupsDescription")} />

{t("admin.section.encryption.backupsDetail")}

-
    - {s.backups.stored.map((row) => ( -
  • - {t("admin.section.encryption.backupsStoredLine", { - key: row.keyId, - count: row.copies, - when: fmt.dateTime(row.oldestAt), - })} -
  • - ))} - {s.backups.unrecorded.copies > 0 && s.backups.unrecorded.oldestAt ? ( -
  • - {t("admin.section.encryption.backupsUnrecordedLine", { - count: s.backups.unrecorded.copies, - when: fmt.dateTime(s.backups.unrecorded.oldestAt), - })} -
  • - ) : null} - {s.backups.offhost.map((row) => ( -
  • - {row.neededUntil - ? t("admin.section.encryption.backupsOffhostLine", { - key: row.keyId, - when: fmt.dateTime(row.lastWrittenAt), - until: fmt.dateTime(row.neededUntil), - }) - : t("admin.section.encryption.backupsOffhostLineUnknown", { - key: row.keyId, - when: fmt.dateTime(row.lastWrittenAt), - })} -
  • - ))} - {s.backups.dumps.map((row) => ( -
  • - {[ - row.local > 0 - ? t("admin.section.encryption.backupsDumpLocalLine", { - key: row.keyId, - count: row.local, - }) - : null, - row.offhost - ? row.offhost.neededUntil - ? t("admin.section.encryption.backupsDumpOffhostLine", { - key: row.keyId, - when: fmt.dateTime(row.offhost.lastWrittenAt), - until: fmt.dateTime(row.offhost.neededUntil), - }) - : t( - "admin.section.encryption.backupsDumpOffhostLineUnknown", - { - key: row.keyId, - when: fmt.dateTime(row.offhost.lastWrittenAt), - }, - ) - : null, - ] - .filter((line): line is string => line !== null) - .join(" ")} -
  • - ))} - {s.backups.stored.length === 0 && - s.backups.unrecorded.copies === 0 && - s.backups.offhost.length === 0 && - s.backups.dumps.length === 0 ? ( -
  • - {t("admin.section.encryption.backupsNone")} -
  • - ) : null} -
+ {/* ── Rotation status + trigger ──────────────────────────────── */} @@ -565,6 +496,85 @@ export function ColumnCoverage({ ); } +/** Per key id, the copies and dumps that still need it. */ +function BackupKeyNeedsList({ + backups, +}: { + backups: EncryptionStatus["backups"]; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + return ( +
    + {backups.stored.map((row) => ( +
  • + {t("admin.section.encryption.backupsStoredLine", { + key: row.keyId, + count: row.copies, + when: fmt.dateTime(row.oldestAt), + })} +
  • + ))} + {backups.unrecorded.copies > 0 && backups.unrecorded.oldestAt ? ( +
  • + {t("admin.section.encryption.backupsUnrecordedLine", { + count: backups.unrecorded.copies, + when: fmt.dateTime(backups.unrecorded.oldestAt), + })} +
  • + ) : null} + {backups.offhost.map((row) => ( +
  • + {row.neededUntil + ? t("admin.section.encryption.backupsOffhostLine", { + key: row.keyId, + when: fmt.dateTime(row.lastWrittenAt), + until: fmt.dateTime(row.neededUntil), + }) + : t("admin.section.encryption.backupsOffhostLineUnknown", { + key: row.keyId, + when: fmt.dateTime(row.lastWrittenAt), + })} +
  • + ))} + {backups.dumps.map((row) => ( +
  • + {[ + row.local > 0 + ? t("admin.section.encryption.backupsDumpLocalLine", { + key: row.keyId, + count: row.local, + }) + : null, + row.offhost + ? row.offhost.neededUntil + ? t("admin.section.encryption.backupsDumpOffhostLine", { + key: row.keyId, + when: fmt.dateTime(row.offhost.lastWrittenAt), + until: fmt.dateTime(row.offhost.neededUntil), + }) + : t("admin.section.encryption.backupsDumpOffhostLineUnknown", { + key: row.keyId, + when: fmt.dateTime(row.offhost.lastWrittenAt), + }) + : null, + ] + .filter((line): line is string => line !== null) + .join(" ")} +
  • + ))} + {backups.stored.length === 0 && + backups.unrecorded.copies === 0 && + backups.offhost.length === 0 && + backups.dumps.length === 0 ? ( +
  • + {t("admin.section.encryption.backupsNone")} +
  • + ) : null} +
+ ); +} + function Stat({ label, value }: { label: string; value: string }) { return ; } diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index f325a6c8e..2a1388eda 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -8,6 +8,10 @@ vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); vi.stubEnv("ENCRYPTION_KEY", "0".repeat(64)); import { NextRequest, NextResponse } from "next/server"; +const pendingOwner = vi.hoisted(() => ({ + value: null as null | { deletionRequestedAt: Date; deletionPurgeAt: null }, +})); + vi.mock("@/lib/db", () => ({ prisma: { idempotencyKey: { @@ -21,6 +25,9 @@ vi.mock("@/lib/db", () => ({ apiToken: { findUnique: vi.fn(), }, + user: { + findUnique: vi.fn(async () => pendingOwner.value), + }, }, })); @@ -430,12 +437,28 @@ describe("defaultUserIdResolver (audit C-4)", () => { vi.mocked(getSession).mockResolvedValue(null); mockHeader("Bearer hlk_abcdef"); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-pending", + name: "connector", userId: "u-bearer", - revoked: false, + createdAt: new Date(), expiresAt: null, - user: { deletionRequestedAt: new Date() }, - } as never); - expect(await defaultUserIdResolver()).toBeNull(); + tokenHash: "hashed:hlk_abcdef", + permissions: ["*"], + lastUsedAt: null, + revoked: false, + mcpConnectionId: null, + mirrorToAppleHealth: false, + mirrorChangedAt: null, + }); + pendingOwner.value = { + deletionRequestedAt: new Date(), + deletionPurgeAt: null, + }; + try { + expect(await defaultUserIdResolver()).toBeNull(); + } finally { + pendingOwner.value = null; + } }); it("rejects expired Bearer tokens", async () => { diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 3429c4210..5601ebf19 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -30,6 +30,7 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { pendingDeletionOf } from "@/lib/account-deletion/pending"; import { readClaimedRecordContext, selectorNamesAnAccount, @@ -528,18 +529,16 @@ export function isCachableStatus(status: number): boolean { } /** - * Default resolver: cookie session first, then Bearer token. The Bearer - * fallback is what makes idempotency actually fire for native iOS / n8n - * /external clients — without it, every Bearer-authed retry was running - * the handler again and creating duplicate measurements (audit C-4). + * Default resolver: cookie session first, then Bearer token, without which + * a Bearer retry (iOS, n8n) ran the handler again and duplicated readings + * (audit C-4). A pending account's token resolves nobody, as in `bearer.ts`. * * It resolves the CALLER and only the caller — which record they are aiming at * is a separate question, answered by `readClaimedRecordContext()` above and * folded into the key. Neither answer authorises anything: the handler runs * its own `requireAuth()` / `requireRecordAuth()` either way. * - * Exported for unit testing; production callers should let - * `withIdempotency()` pick this up automatically via its default arg. + * Exported for unit testing; `withIdempotency()` uses it by default. */ export async function defaultUserIdResolver(): Promise { const session = await getSession().catch(() => null); @@ -558,19 +557,12 @@ export async function defaultUserIdResolver(): Promise { const apiToken = await prisma.apiToken .findUnique({ where: { tokenHash: tokenHashValue }, - select: { - userId: true, - revoked: true, - expiresAt: true, - user: { select: { deletionRequestedAt: true } }, - }, + select: { userId: true, revoked: true, expiresAt: true }, }) .catch(() => null); - // An account pending deletion replays nothing, as `resolveBearerToken` - // refuses it. if (!apiToken || apiToken.revoked) return null; - if (apiToken.user?.deletionRequestedAt) return null; + if (await pendingDeletionOf(apiToken.userId)) return null; if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) return null; return apiToken.userId; } diff --git a/src/lib/jobs/__tests__/pending-deletion-gate.test.ts b/src/lib/jobs/__tests__/pending-deletion-gate.test.ts index 51f6d2c49..6341056d6 100644 --- a/src/lib/jobs/__tests__/pending-deletion-gate.test.ts +++ b/src/lib/jobs/__tests__/pending-deletion-gate.test.ts @@ -24,9 +24,29 @@ vi.mock("@/lib/db", () => ({ const { dropJobsOfPendingAccounts, accountOfJob } = await import("@/lib/jobs/pending-deletion-gate"); const { bindableHandler } = await import("@/lib/jobs/registry/register"); +type JobDefinition = import("@/lib/jobs/registry/define-job").JobDefinition; const { prisma } = await import("@/lib/db"); -const job = (id: string, data: unknown) => ({ id, data }) as Job; +const job = (id: string, data: unknown): Job => ({ + id, + name: "rollup-rebuild", + data, + expireInSeconds: 900, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, +}); + +/** A queue declaration around `handler`, as the registry holds one. */ +function definition( + queue: string, + handler: ( + jobs: Job[], + ) => Promise<{ ok: true; did: Record }>, + extra: { actsForPendingAccounts?: true } = {}, +): JobDefinition { + return { queue, work: {}, load: async () => handler, ...extra }; +} describe("the pending-deletion gate on queued jobs", () => { it("reads the account a payload names, and nothing else", () => { @@ -58,12 +78,13 @@ describe("the pending-deletion gate on queued jobs", () => { it("keeps a bound handler from running a job queued before the deletion", async () => { pending.clear(); pending.add("frozen"); - const handler = vi.fn(async () => ({ ok: true as const, did: {} })); - const load = vi.fn(async () => handler); - const bound = bindableHandler( - { queue: "rollup-rebuild", work: {}, load } as never, - { boss: {} as PgBoss }, - ); + const handler = vi.fn(async (_jobs: Job[]) => ({ + ok: true as const, + did: {}, + })); + const declared = definition("rollup-rebuild", handler); + const load = vi.spyOn(declared, "load"); + const bound = bindableHandler(declared, { boss: {} as PgBoss }); const outcome = await bound([job("1", { userId: "frozen" })]); expect(outcome).toMatchObject({ @@ -74,23 +95,23 @@ describe("the pending-deletion gate on queued jobs", () => { await bound([job("2", { userId: "frozen" }), job("3", { userId: "u" })]); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith([job("3", { userId: "u" })]); + expect(handler.mock.calls[0]?.[0].map((j) => j.id)).toEqual(["3"]); }); it("lets a queue that only changes how rows are stored run for it", async () => { pending.clear(); pending.add("frozen"); - const handler = vi.fn(async () => ({ ok: true as const, did: {} })); + const handler = vi.fn(async (_jobs: Job[]) => ({ + ok: true as const, + did: {}, + })); const bound = bindableHandler( - { - queue: "free-text-encryption-backfill", - work: {}, + definition("free-text-encryption-backfill", handler, { actsForPendingAccounts: true, - load: async () => handler, - } as never, + }), { boss: {} as PgBoss }, ); await bound([job("1", { userId: "frozen" })]); - expect(handler).toHaveBeenCalledWith([job("1", { userId: "frozen" })]); + expect(handler.mock.calls[0]?.[0].map((j) => j.id)).toEqual(["1"]); }); }); diff --git a/src/lib/jobs/__tests__/restore-drill.test.ts b/src/lib/jobs/__tests__/restore-drill.test.ts index abad40385..f72469026 100644 --- a/src/lib/jobs/__tests__/restore-drill.test.ts +++ b/src/lib/jobs/__tests__/restore-drill.test.ts @@ -290,10 +290,20 @@ describe("handleRestoreDrill", () => { "2026-06-01/user-aaa.json.enc": backupObject({ userId: "user-aaa" }), }); const offhost = await import("../offhost-backup"); - vi.spyOn(offhost, "getS3Client").mockResolvedValue(s3 as never); + vi.spyOn(offhost, "getS3Client").mockResolvedValue(s3); // The job's budget is already spent: pg-boss aborted it. + const aborted = new AbortController(); + aborted.abort(); await handleRestoreDrill([ - { signal: { aborted: true }, expireInSeconds: 900 } as never, + { + id: "drill-1", + name: RESTORE_DRILL_QUEUE, + data: {}, + expireInSeconds: 900, + heartbeatSeconds: null, + retryCount: 0, + signal: aborted.signal, + }, ]); expect(recordRestoreDrillOutcome).toHaveBeenCalledTimes(1); expect(vi.mocked(recordRestoreDrillOutcome).mock.calls[0][0]).toMatchObject( diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index f4395f37a..25989480d 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -150,22 +150,20 @@ export async function runCoachReminderSweep( ); try { const plansLapsed = await prisma.coachPlan.updateMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, status: "proposed", createdAt: { lt: lapseBefore }, - ...OWNED_BY_ACTIVE_ACCOUNT, - }, + }), data: { status: "abandoned", reviewDate: null }, }); summary.planProposalsLapsed = plansLapsed.count; const factsLapsed = await prisma.coachFact.updateMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, source: PROPOSED_FACT_SOURCE, createdAt: { lt: lapseBefore }, - ...OWNED_BY_ACTIVE_ACCOUNT, - }, + }), data: { deletedAt: now }, }); summary.factProposalsLapsed = factsLapsed.count; @@ -333,23 +331,21 @@ export async function runCoachReminderSweep( const notHeld = heldOwners.length > 0 ? { userId: { notIn: heldOwners } } : {}; await prisma.coachReminder.updateMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, status: "surfaced", lastSurfacedAt: { lte: new Date(now.getTime() - NAG_STALE_MS) }, ...notHeld, - ...OWNED_BY_ACTIVE_ACCOUNT, - }, + }), data: { surfaceCount: { increment: 1 } }, }); const capped = await prisma.coachReminder.updateMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, status: "surfaced", surfaceCount: { gte: REMINDER_NAG_CAP }, ...notHeld, - ...OWNED_BY_ACTIVE_ACCOUNT, - }, + }), data: { status: "dismissed" }, }); summary.nagDismissed = capped.count; diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 6f3a81f6b..499689940 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -153,7 +153,6 @@ async function claimReaction( `; } - // account-deletion: one row of the account this arrival belongs to, by id. const advanced = await tx.arrivalReaction.updateMany({ where: { id: current.id, occurredAt: { lt: occurredAt } }, data: { diff --git a/src/lib/jobs/document-ai-run.ts b/src/lib/jobs/document-ai-run.ts index d3eae75c8..4df270a6a 100644 --- a/src/lib/jobs/document-ai-run.ts +++ b/src/lib/jobs/document-ai-run.ts @@ -179,7 +179,6 @@ async function armDeadline( "The AI response time is set longer than a background read may run.", }); } - // account-deletion: the one run this job claimed, by id. await prisma.documentAiRun.updateMany({ where: { id: runId, status: "RUNNING" }, data: { expiresAt: new Date(ends) }, diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 23ef5e980..1e265a750 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -527,7 +527,6 @@ async function applyDecision( if (decision.kind === "convert") { // Conditional: still pending at the moment of the write. A take or skip // the person records concurrently makes this match nothing. - // account-deletion: the one row this decision is about, by id. const { count } = await prisma.medicationIntakeEvent.updateMany({ where: { id: decision.rowId, diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 2e91053be..bea85b8c1 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -765,7 +765,6 @@ async function recordWorkerFailure( const retryLimit = metadata.retryLimit ?? 0; const terminal = retryCount >= retryLimit; const now = new Date(); - // account-deletion: the one import this job runs, by id. await client.medicationIntakeImportJob.updateMany({ where: { id: job.data.jobId, diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 4994d156b..7621f9f19 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -43,6 +43,7 @@ import { LIFECYCLE_MISSING, LIFECYCLE_UNKNOWN, lifecycleFromRules, + standOnLastAnswer, type OffhostLifecycle, } from "./offhost-lifecycle"; @@ -758,22 +759,8 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise { const LIFECYCLE_CACHE_MS = 10 * 60 * 1000; let lifecycleCache: { at: number; value: OffhostLifecycle } | null = null; -/** - * The last answer the bucket actually gave (`configured` or `missing`), and - * how long it stands in for a read that failed. A probe that timed out once - * read as "unknown", which keeps every key an off-host object ever needed - * in the recovery kit: the kit turned due, the admins were pushed, and the - * next good read pushed "recovered". A rule does not change between two - * reads an hour apart; a credential that has lost the right to read it for a - * whole day does say something, and then "unknown" is the honest answer. - */ -const LAST_KNOWN_LIFECYCLE_MS = 24 * 60 * 60 * 1000; -let lastKnownLifecycle: { at: number; value: OffhostLifecycle } | null = null; - -/** - * The bucket's lifecycle rule for the admin card, cached for ten minutes so a - * page render does not become a bucket call. Never throws. - */ +/** The bucket's lifecycle rule for the admin card, cached for ten minutes so + * a page render does not become a bucket call. Never throws. */ export async function probeOffhostLifecycle( s3Override?: S3Like, now: number = Date.now(), @@ -787,21 +774,14 @@ export async function probeOffhostLifecycle( } const cfg = loadOffhostConfigSafe(); if (!cfg) return LIFECYCLE_UNKNOWN; - let value: OffhostLifecycle; + let read: OffhostLifecycle = LIFECYCLE_UNKNOWN; try { const s3 = s3Override ?? (await getS3Client(cfg)); - value = s3.getLifecycle ? await s3.getLifecycle() : LIFECYCLE_UNKNOWN; + if (s3.getLifecycle) read = await s3.getLifecycle(); } catch { - value = LIFECYCLE_UNKNOWN; - } - if (value.state !== "unknown") { - lastKnownLifecycle = { at: now, value }; - } else if ( - lastKnownLifecycle && - now - lastKnownLifecycle.at < LAST_KNOWN_LIFECYCLE_MS - ) { - value = lastKnownLifecycle.value; + // A failed read stands on the last answer the bucket gave. } + const value = standOnLastAnswer(read, now); if (!s3Override) lifecycleCache = { at: now, value }; return value; } @@ -953,7 +933,6 @@ export async function runOffhostBackup( // The account was deleted or wiped while its copy was being written. // The purge that request started may already have run, so the copy // this run just put there is removed here rather than left behind. - // account-deletion: the one account this upload is for. const purgedMeanwhile = (await prisma.offhostPurgeRequest.count({ where: { diff --git a/src/lib/jobs/offhost-lifecycle.ts b/src/lib/jobs/offhost-lifecycle.ts index f80364ebf..fcb51e6ab 100644 --- a/src/lib/jobs/offhost-lifecycle.ts +++ b/src/lib/jobs/offhost-lifecycle.ts @@ -89,3 +89,30 @@ export function lifecycleFromRules( ), }; } + +/** + * The last answer the bucket actually gave (`configured` or `missing`), and + * how long it stands in for a read that failed. A probe that timed out once + * read as "unknown", which keeps every key an off-host object ever needed + * in the recovery kit: the kit turned due, the admins were pushed, and the + * next good read pushed "recovered". A rule does not change between two + * reads an hour apart; a credential that has lost the right to read it for a + * whole day does say something, and then "unknown" is the honest answer. + */ +const LAST_KNOWN_LIFECYCLE_MS = 24 * 60 * 60 * 1000; +let lastKnownLifecycle: { at: number; value: OffhostLifecycle } | null = null; + +/** `value`, or the last answer the bucket gave when `value` is unknown. */ +export function standOnLastAnswer( + value: OffhostLifecycle, + now: number, +): OffhostLifecycle { + if (value.state !== "unknown") { + lastKnownLifecycle = { at: now, value }; + return value; + } + return lastKnownLifecycle && + now - lastKnownLifecycle.at < LAST_KNOWN_LIFECYCLE_MS + ? lastKnownLifecycle.value + : value; +} diff --git a/src/lib/jobs/restore-drill-outcome.ts b/src/lib/jobs/restore-drill-outcome.ts index c176e9d25..11c24b031 100644 --- a/src/lib/jobs/restore-drill-outcome.ts +++ b/src/lib/jobs/restore-drill-outcome.ts @@ -39,11 +39,11 @@ export async function recordRestoreDrillOutcome( result.ok !== false ? null : (() => { - const text = redactSecrets((result.reason ?? "").trim()); - return text.length > MAX_REASON_CHARS - ? `${text.slice(0, MAX_REASON_CHARS)}…` - : text || null; - })(); + const text = redactSecrets((result.reason ?? "").trim()); + return text.length > MAX_REASON_CHARS + ? `${text.slice(0, MAX_REASON_CHARS)}…` + : text || null; + })(); const verdict = result.ok === null ? {} diff --git a/src/lib/managed-profiles/claimable.ts b/src/lib/managed-profiles/claimable.ts new file mode 100644 index 000000000..2e984e592 --- /dev/null +++ b/src/lib/managed-profiles/claimable.ts @@ -0,0 +1,21 @@ +/** + * The profile state a handover link depends on, read the same way by every + * step of a handover: minting, the status panel, the preview and the claim. + */ + +export const CLAIMABLE_PROFILE_SELECT = { + managedProfileAt: true, + deletionRequestedAt: true, +}; + +/** + * Whether a profile can be handed over at all: still managed and not pending + * deletion. A claim of one pending deletion would hand its new owner an + * account locked at once and erased when the window ends; its deletion + * revokes the open link, and the claim asks again under the lock. + */ +export function isClaimableProfile< + P extends { managedProfileAt: Date | null; deletionRequestedAt: Date | null }, +>(profile: P | null): profile is P & { managedProfileAt: Date } { + return Boolean(profile?.managedProfileAt) && !profile?.deletionRequestedAt; +} diff --git a/src/lib/managed-profiles/handover.ts b/src/lib/managed-profiles/handover.ts index a5e1e4bbe..d6d414879 100644 --- a/src/lib/managed-profiles/handover.ts +++ b/src/lib/managed-profiles/handover.ts @@ -41,7 +41,9 @@ import { withdrawConsentInTransaction } from "@/lib/consent/withdrawal"; import { consentKindEnum } from "@/lib/validations/consent"; import { activeGuardianWhere, + CLAIMABLE_PROFILE_SELECT, clearManagedProfileMarker, + isClaimableProfile, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; import { @@ -248,11 +250,7 @@ export async function createHandover(input: { const now = input.now ?? new Date(); return prisma.$transaction((tx) => withManagedProfileLock(tx, input.profileId, async (profile) => { - // A profile pending deletion has left every roster: there is nothing - // to hand over. - if (!profile?.managedProfileAt || profile.deletionRequestedAt) { - throw new HandoverError("not_found"); - } + if (!isClaimableProfile(profile)) throw new HandoverError("not_found"); const guardians = await tx.accountGrant.findMany({ where: { grantorId: profile.id, ...activeGuardianWhere(now) }, @@ -338,9 +336,9 @@ export async function readHandoverStatus(input: { const now = input.now ?? new Date(); const profile = await prisma.user.findUnique({ where: { id: input.profileId }, - select: { managedProfileAt: true, deletionRequestedAt: true }, + select: CLAIMABLE_PROFILE_SELECT, }); - if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null; + if (!isClaimableProfile(profile)) return null; if ( !(await isActiveGuardian(prisma, input.profileId, input.guardianId, now)) ) { @@ -451,8 +449,7 @@ export interface HandoverPreview { * The open link behind a stored row, or null when it can no longer be * claimed: used, withdrawn, expired, a state this code cannot read, a profile * no longer managed or pending deletion, or a creator who lost access. The - * one definition of - * "usable" the preview, the single-sign-on start and the callback share. + * one definition of "usable" the preview, the SSO start and callback share. */ async function usableHandover( row: Awaited>, @@ -468,13 +465,9 @@ async function usableHandover( const profile = await prisma.user.findUnique({ where: { id: row.profileId }, - select: { - displayName: true, - managedProfileAt: true, - deletionRequestedAt: true, - }, + select: { displayName: true, ...CLAIMABLE_PROFILE_SELECT }, }); - if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null; + if (!isClaimableProfile(profile)) return null; if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) { return null; } @@ -535,10 +528,9 @@ export async function usableHandoverForToken( } /** - * v1.43 (#959) — whether the link with this id can still be claimed. The - * single-sign-on legs re-ask on every step, so a link withdrawn or replaced - * while the person is at the identity provider is refused before any account - * changes. The claim re-checks under the lock; this decides nothing alone. + * v1.43 (#959) — whether the link with this id can still be claimed. The SSO + * legs re-ask on every step, so a link withdrawn or replaced meanwhile is + * refused before any account changes; the claim re-checks under the lock. */ export async function isHandoverUsable( handoverId: string, @@ -598,13 +590,7 @@ export async function claimManagedProfile(input: { try { return await prisma.$transaction((tx) => withManagedProfileLock(tx, found.profileId, async (profile) => { - // A profile pending deletion is not claimable: the new owner would be - // locked out at once and erased when the window ends. Its deletion - // revokes the open link too; this holds for a claim that waited on - // the lock while the deletion committed. - if (!profile?.managedProfileAt || profile.deletionRequestedAt) { - throw new HandoverError("invalid"); - } + if (!isClaimableProfile(profile)) throw new HandoverError("invalid"); // Re-read under the lock: a new link, a withdrawal or a second claim // that committed while this one waited is seen here. @@ -881,9 +867,8 @@ export async function decideHandover(input: { }): Promise { const now = input.now ?? new Date(); return prisma.$transaction((tx) => - // The same key every lifecycle act on this record takes. The marker is - // gone, so nothing here depends on it; the lock orders this against a - // second decision from another tab. + // The same key every lifecycle act on this record takes: it orders this + // against a second decision from another tab. withManagedProfileLock(tx, input.userId, async () => { const row = await latestClaimedHandover(tx, input.userId); const state = row ? parseHandoverState(row.proposalsJson) : null; diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 0197f009f..8b91834b0 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -24,12 +24,16 @@ import { type Transaction = Prisma.TransactionClient; export { activeGuardianWhere, remainingGuardianWhere }; +export { + CLAIMABLE_PROFILE_SELECT, + isClaimableProfile, +} from "@/lib/managed-profiles/claimable"; /** * Whether `profileId` keeps a Guardian who is not pending deletion once * `leavingGuardianId` (if any) is gone. Call it under the record lock. */ -async function keepsAGuardian( +export async function keepsAGuardian( tx: Transaction, profileId: string, leavingGuardianId: string | null, @@ -96,16 +100,15 @@ export async function withManagedProfileLock( */ export async function reduceManagedProfileGuardian( tx: Transaction, - profileId: string, - leavingGuardianId: string, + grant: { grantorId: string; granteeId: string }, reduction: () => Promise, ): Promise { - return withManagedProfileLock(tx, profileId, async (profile) => { + return withManagedProfileLock(tx, grant.grantorId, async (profile) => { // A profile pending deletion has left every roster; nobody is stranded. if (!profile?.managedProfileAt || profile.deletionRequestedAt) { return reduction(); } - if (!(await keepsAGuardian(tx, profile.id, leavingGuardianId))) { + if (!(await keepsAGuardian(tx, profile.id, grant.granteeId))) { throw new LastManagedGuardianError(); } return reduction(); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index c1828e5d4..3829b58f0 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -615,7 +615,6 @@ async function convergeAdhocRowsOntoPendingSlots(input: { if (pending.takenAt !== null || pending.skipped) continue; // already served. // Tombstone the pending row; the take is the dose of record. - // account-deletion: one row of the account this pass was handed, by id. await prisma.medicationIntakeEvent.updateMany({ where: { id: pending.id, deletedAt: null }, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 2b49765fd..e3d61ae11 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -40,7 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes"; */ export const ERROR_CODE_CATALOGUE: Readonly> = { - account: ["account.deletion.not_pending"], + account: ["account.deletion.no_guardian", "account.deletion.not_pending"], activity: ["activity.calendar.future_date", "activity.calendar.too_old"], ai: [ "ai.budget.exceeded", diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 200799b83..ad330a2e9 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -997,11 +997,10 @@ async function persistRollupRows( : null; if (rows.length === 0) { - // Nothing to write. With a prune scope there is still work to do: the - // window is authoritatively empty, so every bucket in it must go. No - // transaction needed — there is no insert leg that could leave a gap. + // Nothing to write. With a prune scope the window is authoritatively + // empty and every bucket in it goes; no insert leg, so no transaction. + // account-deletion: `pruneWhere` is the one account this fold was handed. if (!pruneWhere) return 0; - // account-deletion: scoped to the one account this fold was handed (`pruneWhere` carries its userId). await prisma.measurementRollup.deleteMany({ where: pruneWhere }); return 0; } @@ -1053,21 +1052,18 @@ async function persistRollupRows( // COMMITTED lets a later statement see it — without aborting the // batch. // - // Transaction sizing: one call covers ONE granularity, so the worst - // case is the full 5-year DAY fold of a multi-source power user — - // tens of thousands of rows in 500-row chunks, well inside Postgres - // limits but potentially slow on self-host disks. A per-type - // transaction was considered and rejected: it would re-open the - // committed-delete gap between types sharing the bucket range. The - // 120 s timeout (Prisma default: 5 s) covers the chunk round-trips - // with headroom; the incremental hook path is a single sub-500-row - // chunk and finishes far inside it. + // Transaction sizing: one call covers ONE granularity, so the worst case + // is a multi-source power user's 5-year DAY fold: tens of thousands of + // rows in 500-row chunks, slow on self-host disks. A per-type transaction + // would re-open the committed-delete gap between types sharing the bucket + // range. The 120 s timeout (Prisma default: 5 s) covers the round-trips; + // the incremental hook path is a single sub-500-row chunk. let touched = 0; await prisma.$transaction( async (tx) => { if (statementTimeoutMs) await applyStatementBudget(tx, statementTimeoutMs); - // account-deletion: scoped to the one account this fold was handed (`deleteWhere` carries its userId). + // account-deletion: `deleteWhere` is the one account this fold was handed. await tx.measurementRollup.deleteMany({ where: deleteWhere }); for (let i = 0; i < ordered.length; i += CHUNK) { const slice = ordered.slice(i, i + CHUNK); diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 196066420..e9d12ff28 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -62,8 +62,8 @@ import { activeGuardianWhere, LastManagedGuardianError, ManagedProfileLifecycleError, + keepsAGuardian, reduceManagedProfileGuardian, - remainingGuardianWhere, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; import { @@ -410,16 +410,9 @@ export async function revokeManagedProfileGuardian(input: { const targetIsActive = target.acceptedAt !== null && (target.expiresAt === null || target.expiresAt > now); - if (targetIsActive) { - // Another Guardian who is not pending deletion must stay. - const remaining = await tx.accountGrant.count({ - where: { - grantorId: profile.id, - granteeId: { not: target.granteeId }, - ...remainingGuardianWhere(now), - }, - }); - if (remaining === 0) throw new LastManagedGuardianError(); + const leaves = targetIsActive ? target.granteeId : null; + if (leaves && !(await keepsAGuardian(tx, profile.id, leaves))) { + throw new LastManagedGuardianError(); } await tx.accountGrant.update({ @@ -769,12 +762,7 @@ async function endGrant( select: { id: true }, }); if (!live) return end(); - return reduceManagedProfileGuardian( - db, - current.grantorId, - current.granteeId, - end, - ); + return reduceManagedProfileGuardian(db, current, end); }); }