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

- + {/* ── 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); }); }