From 36925d31648f79f928a7473970ee35bb77d35317 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 24 Aug 2026 17:52:47 -0400 Subject: [PATCH 1/2] #562 let withdrawn applicants edit file answers like text ones Both hardcoded 'draft' gates in question-files.ts (the pre-check and the in-transaction findFirst) now use APPLICANT_EDITABLE_APPLICATION_STATUSES, matching the text-answer path. APPLICATION_NOT_EDITABLE_MESSAGE moves to lib/constants.ts so both answer types share one sentence. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/positions/[id]/apply/page.tsx | 7 +- docs/PERMISSIONS.md | 3 +- docs/WORKFLOWS.md | 4 +- lib/constants.ts | 13 ++ prisma/actions/applications.ts | 19 +- prisma/actions/question-files.ts | 28 ++- tests/db/application-transitions.test.ts | 3 +- tests/db/question-file-answers.test.ts | 189 ++++++++++++++++++ tests/stubs/vercel-blob.ts | 33 +++ vitest.config.ts | 3 + 10 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 tests/db/question-file-answers.test.ts create mode 100644 tests/stubs/vercel-blob.ts diff --git a/app/(main)/(auth)/positions/[id]/apply/page.tsx b/app/(main)/(auth)/positions/[id]/apply/page.tsx index 8a7c39a6..6dd0eb25 100644 --- a/app/(main)/(auth)/positions/[id]/apply/page.tsx +++ b/app/(main)/(auth)/positions/[id]/apply/page.tsx @@ -11,9 +11,9 @@ import { getProfileData } from '@/prisma/data/profile'; import { withRedirectTo } from '@/lib/auth/redirect'; import { getCurrentUser } from '@/lib/auth/server'; import { - APPLICANT_EDITABLE_APPLICATION_STATUSES, APPLICATION_STATUS_LABELS, UNRESOLVED_APPLICATION_STATUSES, + isApplicantEditableApplicationStatus, } from '@/lib/constants'; import { isAcceptingApplications, @@ -72,10 +72,7 @@ export default async function ApplyPage({ params }: ApplyPageProps) { .every((d) => isAnswered(d.question, toStringArray(d.answer?.value))); const isEditable = - application && - APPLICANT_EDITABLE_APPLICATION_STATUSES.includes( - application.status as (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number], - ); + application && isApplicantEditableApplicationStatus(application.status); const isResubmit = application?.status === 'withdrawn'; diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 32006cc0..a7640977 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -58,7 +58,7 @@ Four principals, each derived rather than stored as a single role field: | `applications.ts` — `withdrawApplication` | `getCurrentUser()` | `updateMany` scoped to `userId` + `status: { notIn: ['draft', 'withdrawn', ...TERMINAL_DECISION_STATUSES] }` | `{ error }` when the scoped update hits 0 rows | | `applications.ts` — `updateApplicationStatus` | query-scoped via `buildApplicationWhere(user, 'reviewable')` (no named guard) | miss → **throw** (IDOR-style, unreachable from the UI, not `{ error }`); stale-but-visible transition → `{ error }` from `isAllowedApplicationStatusTransition` | throw on scope miss, `{ error }` on invalid transition or write race | | `applications.ts` — `updateApplicationStatuses` (bulk) | authorization **folded into the `updateMany` where** (`buildApplicationScopeWhere(user)` + `status: { in: getApplicationStatusForwardSources(status) }`) | ineligible rows are skipped, not walked backward | `{ updated, skipped }` — a skip count, never an error, for rows outside the caller's scope or transition graph | -| `question-files.ts` — file answer actions | `authorizeTarget` gates on the application being `draft` | draft-scoped lookup (`tx.application.findFirst({ id, status: 'draft' })`) | `{ error: SUBMITTED_ERROR }` once submitted | +| `question-files.ts` — file answer actions | `getCurrentUser()` + `authorizeTarget` (ownership miss throws) | gated on `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`) in **both** the pre-check and the in-transaction `findFirst`, matching the text-answer path | `{ error: APPLICATION_NOT_EDITABLE_MESSAGE }` | | `question-files.ts` — `cleanupOrphanedBlob` | **none** — exported from this `'use server'` file with no auth guard | reference-counts the URL across all three answer tables; deletes only at zero references, swallows errors | see [Known-open deviations](#known-open-deviations) | | `position-actions.ts` — `createPosition`, `searchUsers` | `requireManagerOrAdmin()` | — | throw | | `position-actions.ts` — `updatePosition` | `getCurrentUser()` → existence check → `requirePositionAccess(id)` → `checkPositionEditable` | archived positions rejected even for their own manager | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` | @@ -179,6 +179,5 @@ The policy constrains future transitions only, so nothing here needs a data migr | `updatePosition` accepts any status → any status: a manager can unpublish a position with live applications, or "reopen" one past its `closesAt` and get a silent no-op | `prisma/actions/position-actions.ts` | #526 | | `createPositionSchema` accepts `status: 'closed'` | `prisma/actions/position-actions.ts` | #526 | | No `closesAt` ≥ `opensAt` validation anywhere; pre-existing rows may already violate it, so the error must name both fields | `createPositionSchema`, `updatePositionSchema`, `positionFormSchema` | #527 | -| File answers are draft-only (`authorizeTarget` returns `SUBMITTED_ERROR` for any non-`draft` status) while text answers follow `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`) — a withdrawn application can edit its text answers but not replace a file | `prisma/actions/question-files.ts` | #562 | | `cleanupOrphanedBlob` is an unauthenticated server action taking an arbitrary URL. Impact is bounded (deletes only at zero references, swallows errors), but it should not be exported from a `'use server'` module | `prisma/actions/question-files.ts` | #563 | | `WORKFLOWS.md` XC-3 describes a profile-completeness redirect gate in `app/(main)/(auth)/layout.tsx` that no longer exists — that layout now only does `getCurrentUser` + `requireName`; the only completeness check left is the apply page's "Complete your profile first" card | `docs/WORKFLOWS.md` | #567 | diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index feb9caeb..2ad59cc3 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -252,7 +252,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Failure / edge** - Not PDF/PNG/JPG, or the sniffed type disagrees with the extension → **"Only PDF, PNG and JPG files are allowed."** - Over `FILE_UPLOAD_MAX_BYTES` (4 MB) → **"File must be 4MB or smaller."**; empty file → **"Select a file to upload."** The helper text reads "PDF, PNG or JPG · up to 4MB". - - The application was submitted in another tab → **"This application has already been submitted."** and the freshly uploaded blob is deleted rather than orphaned. + - The application left an applicant-editable status in another tab, or was decided → **"This application has already been submitted. Withdraw it to make changes."** and the freshly uploaded blob is deleted rather than orphaned. - Ownership miss on the application, or a question that isn't a `file_upload` on this position → the action throws → generic toast. - Blob storage failure → throws → generic toast; nothing is written. - **End state** — the answer's `value` holds exactly one blob URL (or none after a remove). Orphaned blobs are swept by `cleanupOrphanedBlob`, which is best-effort and never surfaces an error. @@ -314,7 +314,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-14 Edit and resubmit a withdrawn application - **Trigger** — **Edit & resubmit** on a withdrawn row in `/my-applications`, which returns to `/positions/[id]/apply`. -- **Happy path** — `withdrawn` is applicant-editable, so the stepper reopens with every answer intact, above an info callout: "This application is withdrawn — It's out of the review queue, but reviewers can still see your answers — including edits you make here. Resubmit to put it back in the queue." Submitting runs [AP-9](#ap-9-submit-an-application) and toasts **"Application resubmitted"**. +- **Happy path** — `withdrawn` is applicant-editable, so the stepper reopens with every answer intact and editable, files included, above an info callout: "This application is withdrawn — It's out of the review queue, but reviewers can still see your answers — including edits you make here. Resubmit to put it back in the queue." Submitting runs [AP-9](#ap-9-submit-an-application) and toasts **"Application resubmitted"**. - **Failure / edge** - The window closed while it was withdrawn → the row shows "Position closed" instead of the button, and the apply page renders "Applications are closed" ("This position stopped accepting applications, so this application can no longer be edited or submitted."). - Every [AP-9](#ap-9-submit-an-application) failure branch applies unchanged. diff --git a/lib/constants.ts b/lib/constants.ts index 62886ea7..bc379822 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -593,6 +593,19 @@ export const APPLICANT_EDITABLE_APPLICATION_STATUSES = [ 'withdrawn', ] as const satisfies $Enums.ApplicationStatus[]; +// Narrows a status without an unsafe cast at each call site. +export function isApplicantEditableApplicationStatus( + status: $Enums.ApplicationStatus, +): status is (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number] { + return ( + APPLICANT_EDITABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(status); +} + +// Shared by the text- and file-answer edit paths so the sentence can't drift. +export const APPLICATION_NOT_EDITABLE_MESSAGE = + 'This application has already been submitted. Withdraw it to make changes.'; + // Includes 'draft' (unlike UNRESOLVED): a draft-only applicant still needs attention. export const NON_TERMINAL_APPLICATION_STATUSES = [ 'draft', diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index f3b50644..d088d1ee 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -22,7 +22,7 @@ import { getCurrentUser } from '@/lib/auth/server'; import { ANSWER_LONG_MAX_LENGTH, ANSWER_MAX_VALUES, - APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_NOT_EDITABLE_MESSAGE, APPLICATION_STATUS_LABELS, REVIEWER_APPLICATION_STATUSES, SHORT_ANSWER_FORMAT_ERROR_MESSAGES, @@ -30,6 +30,7 @@ import { getAnswerValueError, getApplicationStatusForwardSources, isAllowedApplicationStatusTransition, + isApplicantEditableApplicationStatus, matchesShortAnswerFormat, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; @@ -85,10 +86,6 @@ const createOrUpdateApplicationAnswerSchema = z.object({ const submitApplicationSchema = z.object({ applicationId: z.string().min(1) }); -// Shared so callers don't distinguish which action rejected the write. -const APPLICATION_NOT_EDITABLE_MESSAGE = - 'This application has already been submitted. Withdraw it to make changes.'; - // Interaction-time only — never called during render (see apply/page.tsx). export async function createDraftApplication( input: unknown, @@ -198,11 +195,7 @@ export async function createOrUpdateApplicationAnswer(params: { requireOwnership(application, currentUser.id); - if ( - !APPLICANT_EDITABLE_APPLICATION_STATUSES.includes( - application.status as (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number], - ) - ) + if (!isApplicantEditableApplicationStatus(application.status)) return { error: APPLICATION_NOT_EDITABLE_MESSAGE }; // Label and scope must come from the DB, not the client — the label is the @@ -351,11 +344,7 @@ export async function submitApplication( requireOwnership(application, currentUser.id); // Status check first — wins over the window/required-answer checks below. - if ( - !APPLICANT_EDITABLE_APPLICATION_STATUSES.includes( - application.status as (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number], - ) - ) + if (!isApplicantEditableApplicationStatus(application.status)) return { error: APPLICATION_NOT_EDITABLE_MESSAGE }; // A draft's position can be soft-deleted after creation, before submit. diff --git a/prisma/actions/question-files.ts b/prisma/actions/question-files.ts index 23a38fa8..5322882f 100644 --- a/prisma/actions/question-files.ts +++ b/prisma/actions/question-files.ts @@ -9,9 +9,12 @@ import type { Prisma } from '@/prisma/client'; import { getCurrentUser } from '@/lib/auth/server'; import { + APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_NOT_EDITABLE_MESSAGE, FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MIME_EXTENSIONS, FILE_UPLOAD_MIME_TYPES, + isApplicantEditableApplicationStatus, questionFileTargetSchema, } from '@/lib/constants'; import { @@ -24,11 +27,10 @@ import type { QuestionFileDownload, QuestionFileTarget } from '@/lib/types'; import { type ResponseType } from '@/lib/utils'; const GENERIC_TYPE_ERROR = 'Only PDF, PNG and JPG files are allowed.'; -const SUBMITTED_ERROR = 'This application has already been submitted.'; const NOT_AVAILABLE_ERROR = 'This file is no longer available.'; -// Signals the caller to surface SUBMITTED_ERROR rather than rethrow as unexpected. -class ApplicationSubmittedError extends Error {} +// Signals the caller to surface APPLICATION_NOT_EDITABLE_MESSAGE, not rethrow as unexpected. +class ApplicationNotEditableError extends Error {} // Returned verbatim as user-facing copy; question-file-field.tsx mirrors them. const fileSchema = z @@ -54,7 +56,7 @@ type ResolvedTarget = | { scope: 'profile' } | { scope: 'application'; positionId: string }; -// An ownership miss is IDOR-style and throws; a stale tab's submit returns. +// An ownership miss is IDOR-style and throws; a status change returns. async function authorizeTarget( userId: string, target: QuestionFileTarget, @@ -93,7 +95,8 @@ async function authorizeTarget( if (!question) throw new Error('Question not found or not authorized'); } - if (application.status !== 'draft') return { error: SUBMITTED_ERROR }; + if (!isApplicantEditableApplicationStatus(application.status)) + return { error: APPLICATION_NOT_EDITABLE_MESSAGE }; return { scope: 'application', positionId: application.positionId }; } @@ -133,10 +136,13 @@ async function readAndWriteAnswerValue( } const application = await tx.application.findFirst({ - where: { id: target.applicationId, status: 'draft' }, + where: { + id: target.applicationId, + status: { in: [...APPLICANT_EDITABLE_APPLICATION_STATUSES] }, + }, select: { id: true }, }); - if (!application) throw new ApplicationSubmittedError(); + if (!application) throw new ApplicationNotEditableError(); if (target.isGlobal) { const where = { @@ -256,8 +262,8 @@ export async function uploadQuestionFileAnswer( } catch (error) { // A failed write must not orphan the blob just uploaded. await del(blob.url).catch(() => {}); - if (error instanceof ApplicationSubmittedError) - return { error: SUBMITTED_ERROR }; + if (error instanceof ApplicationNotEditableError) + return { error: APPLICATION_NOT_EDITABLE_MESSAGE }; throw error; } @@ -286,8 +292,8 @@ export async function removeQuestionFileAnswer( readAndWriteAnswerValue(tx, target, [], user.id), ); } catch (error) { - if (error instanceof ApplicationSubmittedError) - return { error: SUBMITTED_ERROR }; + if (error instanceof ApplicationNotEditableError) + return { error: APPLICATION_NOT_EDITABLE_MESSAGE }; throw error; } diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index b3bb07d2..ed87a26c 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -32,8 +32,7 @@ import { import { prisma } from '@/lib/prisma'; import { isError } from '@/lib/utils'; -// Mirrors the (deliberately unexported) copy in prisma/actions/applications.ts — -// asserting the exact sentence is the point of this suite. +// Local literal, not the lib/constants.ts export — asserting the exact sentence is the point. const APPLICATION_NOT_EDITABLE_MESSAGE = 'This application has already been submitted. Withdraw it to make changes.'; const WITHDRAW_NOT_ALLOWED_MESSAGE = diff --git a/tests/db/question-file-answers.test.ts b/tests/db/question-file-answers.test.ts new file mode 100644 index 00000000..a5b7f5b3 --- /dev/null +++ b/tests/db/question-file-answers.test.ts @@ -0,0 +1,189 @@ +import { + cleanupFixtures, + createTestApplication, + createTestGlobalQuestion, + createTestPosition, + createTestPositionQuestion, + createTestUser, +} from '@/tests/helpers/fixtures'; +import { actAs } from '@/tests/stubs/auth-server'; +import { deletedUrls, onPut, resetBlobStub } from '@/tests/stubs/vercel-blob'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { + removeQuestionFileAnswer, + uploadQuestionFileAnswer, +} from '@/prisma/actions/question-files'; +import type { $Enums, Position, User } from '@/prisma/client'; + +import { + APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_STATUS_VALUES, +} from '@/lib/constants'; +import { prisma } from '@/lib/prisma'; +import { isError } from '@/lib/utils'; + +// Local literal, not the lib/constants.ts export — asserting the exact sentence is the point. +const APPLICATION_NOT_EDITABLE_MESSAGE = + 'This application has already been submitted. Withdraw it to make changes.'; + +const ALL_STATUSES: $Enums.ApplicationStatus[] = [ + ...APPLICATION_STATUS_VALUES, + 'withdrawn', +]; + +const PDF_BYTES = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, +]); + +function buildFormData(target: { + applicationId: string; + questionId: string; + isGlobal: boolean; +}): FormData { + const formData = new FormData(); + formData.set('scope', 'application'); + formData.set('applicationId', target.applicationId); + formData.set('questionId', target.questionId); + formData.set('isGlobal', String(target.isGlobal)); + formData.set( + 'file', + new File([PDF_BYTES], 'test.pdf', { type: 'application/pdf' }), + ); + return formData; +} + +let admin: User; +let position: Position; + +beforeAll(async () => { + admin = await createTestUser({ isAdmin: true }); + position = await createTestPosition(admin); +}); + +afterEach(() => { + resetBlobStub(); +}); + +afterAll(async () => { + await cleanupFixtures(); +}); + +describe.each([ + { label: 'global', isGlobal: true }, + { label: 'position', isGlobal: false }, +])('question-files ($label scope)', ({ isGlobal }) => { + async function createQuestion(): Promise { + if (isGlobal) { + const question = await createTestGlobalQuestion(admin, { + type: 'file_upload', + required: false, + }); + return question.id; + } + const question = await createTestPositionQuestion(position, admin, { + type: 'file_upload', + required: false, + }); + return question.id; + } + + describe('uploadQuestionFileAnswer', () => { + for (const status of ALL_STATUSES) { + const isLegal = ( + APPLICANT_EDITABLE_APPLICATION_STATUSES as readonly string[] + ).includes(status); + + it(`${isLegal ? 'allows' : 'blocks'} upload from ${status}`, async () => { + const applicant = await createTestUser(); + const questionId = await createQuestion(); + const application = await createTestApplication(applicant, position, { + status, + }); + + actAs(applicant); + const result = await uploadQuestionFileAnswer( + buildFormData({ + applicationId: application.id, + questionId, + isGlobal, + }), + ); + + if (isLegal) expect(isError(result)).toBe(false); + else + expect(result).toEqual({ error: APPLICATION_NOT_EDITABLE_MESSAGE }); + }); + } + }); + + describe('removeQuestionFileAnswer', () => { + for (const status of ALL_STATUSES) { + const isLegal = ( + APPLICANT_EDITABLE_APPLICATION_STATUSES as readonly string[] + ).includes(status); + + it(`${isLegal ? 'allows' : 'blocks'} remove from ${status}`, async () => { + const applicant = await createTestUser(); + const questionId = await createQuestion(); + const application = await createTestApplication(applicant, position, { + status, + }); + + actAs(applicant); + const result = await removeQuestionFileAnswer({ + scope: 'application', + applicationId: application.id, + questionId, + isGlobal, + }); + + if (isLegal) expect(result).toBeUndefined(); + else + expect(result).toEqual({ error: APPLICATION_NOT_EDITABLE_MESSAGE }); + }); + } + }); +}); + +describe('uploadQuestionFileAnswer race', () => { + it('refuses a status change between the pre-check and the write, and deletes the new blob', async () => { + const applicant = await createTestUser(); + const question = await createTestGlobalQuestion(admin, { + type: 'file_upload', + required: false, + }); + const application = await createTestApplication(applicant, position, { + status: 'withdrawn', + }); + + onPut(async () => { + await prisma.application.update({ + where: { id: application.id }, + data: { status: 'applied' }, + }); + }); + + actAs(applicant); + const result = await uploadQuestionFileAnswer( + buildFormData({ + applicationId: application.id, + questionId: question.id, + isGlobal: true, + }), + ); + + expect(result).toEqual({ error: APPLICATION_NOT_EDITABLE_MESSAGE }); + expect(deletedUrls).toHaveLength(1); + + const stored = await prisma.globalApplicationAnswer.findUnique({ + where: { + applicationId_globalQuestionId: { + applicationId: application.id, + globalQuestionId: question.id, + }, + }, + }); + expect(stored).toBeNull(); + }); +}); diff --git a/tests/stubs/vercel-blob.ts b/tests/stubs/vercel-blob.ts new file mode 100644 index 00000000..299a912c --- /dev/null +++ b/tests/stubs/vercel-blob.ts @@ -0,0 +1,33 @@ +// Alias target for @vercel/blob (db project only) — no test may reach real +// blob storage. onPut is the seam question-file-answers.test.ts uses to race +// a status change between the pre-check and the write transaction. +let onPutHook: (() => Promise | void) | null = null; + +export const deletedUrls: string[] = []; + +export function resetBlobStub(): void { + onPutHook = null; + deletedUrls.length = 0; +} + +// Fires once, then clears itself — callers re-arm per test. +export function onPut(fn: () => Promise | void): void { + onPutHook = fn; +} + +export async function put(pathname: string): Promise<{ url: string }> { + if (onPutHook) { + const hook = onPutHook; + onPutHook = null; + await hook(); + } + return { url: `https://blob.test/${pathname}` }; +} + +export async function del(url: string): Promise { + deletedUrls.push(url); +} + +export async function get(): Promise { + throw new Error('blob get not stubbed'); +} diff --git a/vitest.config.ts b/vitest.config.ts index 48ee4852..3e197a52 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,6 +35,9 @@ export default defineConfig({ '@/lib/auth/server': fileURLToPath( new URL('./tests/stubs/auth-server.ts', import.meta.url), ), + '@vercel/blob': fileURLToPath( + new URL('./tests/stubs/vercel-blob.ts', import.meta.url), + ), 'next/cache': fileURLToPath( new URL('./tests/stubs/next-cache.ts', import.meta.url), ), From f1fd25ba49d697b8e2fad0f7664abc9e450c02a5 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 24 Aug 2026 18:05:16 -0400 Subject: [PATCH 2/2] #562 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- tests/stubs/vercel-blob.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/stubs/vercel-blob.ts b/tests/stubs/vercel-blob.ts index 299a912c..c362a2c3 100644 --- a/tests/stubs/vercel-blob.ts +++ b/tests/stubs/vercel-blob.ts @@ -1,6 +1,4 @@ -// Alias target for @vercel/blob (db project only) — no test may reach real -// blob storage. onPut is the seam question-file-answers.test.ts uses to race -// a status change between the pre-check and the write transaction. +// @vercel/blob alias for the db project; onPut is the seam for racing a status change mid-write. let onPutHook: (() => Promise | void) | null = null; export const deletedUrls: string[] = [];