Files
redefined-designs/docs/superpowers/plans/2026-09-03-background-removal.md
bermudalambandClaude Opus 5 1c265840a9 docs(intake): plan the background-removal implementation (#281)
Eight tasks over the approved design, each ending in something independently testable: the two columns, the sidecar client, the shared swap-and-restore, the worker step, the intake route, the admin endpoints, the submitter's checkbox, and the review queue's per-photo control.

Three things the plan pins down that the spec left to implementation.

The `model=u2net` assertion lives in a unit test against a real stub HTTP server rather than a mocked fetch, because what has to be checked is the shape of the request that reaches the wire. Nothing in the returned image would reveal that the non-commercial default had been used, so that assertion is the only thing standing between this and a licensing problem that produces perfectly good pictures.

Removal in the worker follows drafting rather than running on its own pass, which couples the two: an environment with no ANTHROPIC_API_KEY drafts nothing and so cuts out nothing. That is the deliberate trade — a separate pass would re-attempt an unreachable sidecar on every five-minute sweep for a row that is going to sit at `queued` indefinitely — and the plan says so in the worker's own header comment rather than leaving it to be rediscovered.

`removeImageBackground` is idempotent through the `original_image_path IS NOT NULL` check rather than a separate flag, and that guard is load-bearing twice: it makes a repeat call a no-op, and it stops a second pass recording the cut-out as the original and losing the real one for good.

Writing it turned up two things worth knowing about the existing tests. `drafting.integration.test.ts` has never produced a successful draft — every case in it either has no key or no readable photo — so the worker's new cases need their own file with `draftListing` mocked, rather than a mock added file-wide to a suite that deliberately never reaches the model. And `adminItemDrafts.integration.test.ts` calls `request(app)` directly with no helper, so the plan spells out the seed it needs instead of pointing at one that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:18:50 -05:00

76 KiB
Raw Permalink Blame History

Background Removal Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let a submitter ask for the background to be removed from their photos, and let an admin apply or undo it per photo in the review queue — without ever destroying the original.

Architecture: A rembg sidecar does the work over HTTP; no Python enters the Node image. One shared module (backgroundRemoval.ts) performs the swap and the restore, called from two entry points: the drafting worker, honouring an intent the submitter recorded, and two admin endpoints. The original file is kept and its path recorded, so every failure and every poor result is recoverable.

Tech Stack: Express 4 + TypeScript, pg, node-pg-migrate, Jest + supertest, React + antd (antd/es/... deep imports), Playwright.

Spec: docs/superpowers/specs/2026-09-03-background-removal-design.md Issue: #281 Ops notes (measured numbers, the API, the licensing trap): docs/ops/image-background-removal-stack.md

Global Constraints

  • model=u2net is sent on every rembg request, always. The sidecar's default is bria-rmbg, licensed non-commercial, and it is reached by simply not naming a model. A test asserts the parameter is present, because nothing in the output would reveal its absence.
  • Nothing deletes anything. No task unlinks a file or deletes a row. The submitter's photos are often the only copy of an item no longer in their hands.
  • REMBG_URL is optional. Unset means the feature does not exist — no checkbox, no admin control, no worker step — not that the environment is broken. Same rule as ANTHROPIC_API_KEY.
  • Background removal never fails a submission and never fails a draft. Every failure path leaves the row and the file exactly as they were.
  • antd imports are deep and from es: import Checkbox from 'antd/es/checkbox'. Never import { Checkbox } from 'antd'.
  • Branch: feature/281-background-removal off main. Commit subjects end (#281). Do not push — the user pushes.
  • Verify the frontend with npm run build, never bare npx tsc --noEmit — the app tsconfig excludes tests/, and a green tsc once broke a deploy.
  • Integration tests need the test database: cd backend && npm run db:test:up. Port 55432 is Hyper-V-reserved on this machine; if it fails to bind, set TEST_PGPORT rather than editing the compose file.

Task 1: The schema — two columns and the Drizzle mirror

Files:

  • Create: backend/migrations/1787600000000_add-background-removal.js
  • Modify: backend/src/db-drizzle/schema.ts (the itemImages and itemDrafts blocks)
  • Test: backend/tests/integration/backgroundRemoval.integration.test.ts (create)

Interfaces:

  • Consumes: nothing.

  • Produces: item_drafts.remove_background BOOLEAN NOT NULL DEFAULT true, item_images.original_image_path TEXT (nullable), and the test helper seedSubmission(imagePath?). Every later task reads or writes one of these.

  • Step 1: Write the failing test

Create backend/tests/integration/backgroundRemoval.integration.test.ts:

import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';

beforeEach(async () => {
  await resetDb();
});

afterAll(async () => {
  await pool.end();
  await closeDb();
});

/** An item with a draft row and one image, which is what a submission leaves. */
export async function seedSubmission(
  imagePath = '/uploads/photo.jpg'
): Promise<{ itemId: number; imageId: number }> {
  const item = await pool.query<{ id: number }>(
    `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
  );
  const itemId = item.rows[0]!.id;
  await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [itemId]);
  const image = await pool.query<{ id: number }>(
    `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, 0) RETURNING id`,
    [itemId, imagePath]
  );
  return { itemId, imageId: image.rows[0]!.id };
}

describe('the background-removal columns', () => {
  // Default true because the submitter's checkbox is ticked by default, and
  // because a row written by any path that does not mention the column should
  // behave like the new default rather than needing a backfill.
  it('defaults remove_background to true', async () => {
    const { itemId } = await seedSubmission();

    const { rows } = await pool.query<{ remove_background: boolean }>(
      `SELECT remove_background FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(rows[0]?.remove_background).toBe(true);
  });

  // Null is also the answer to "can this be restored?", which is why there is
  // no separate flag: one fact, one place.
  it('leaves original_image_path null until a photo has been cut out', async () => {
    const { imageId } = await seedSubmission();

    const { rows } = await pool.query<{ original_image_path: string | null }>(
      `SELECT original_image_path FROM item_images WHERE id = $1`,
      [imageId]
    );
    expect(rows[0]?.original_image_path).toBeNull();
  });
});
  • Step 2: Run it to verify it fails
cd backend && npm run db:test:up && npm run test:integration -- backgroundRemoval

Expected: FAIL with column "remove_background" does not exist.

  • Step 3: Write the migration

Create backend/migrations/1787600000000_add-background-removal.js:

exports.up = (pgm) => {
  pgm.sql(`
    -- The submitter's intent, per submission, because that is how it is
    -- expressed: one checkbox above the send button, ticked by default.
    --
    -- The worker acts on it rather than the intake route. Removing inline
    -- would make the sender wait, would put a CPU-heavy model run in a path
    -- anyone holding a link can trigger — the surface #227 exists to bound —
    -- and would force a choice, when the sidecar is unreachable, between
    -- failing their submission and silently ignoring what they asked for.
    --
    -- NOT NULL DEFAULT true so a row written before this migration, or by any
    -- path that does not mention the column, behaves like the new default.
    ALTER TABLE item_drafts
      ADD COLUMN IF NOT EXISTS remove_background BOOLEAN NOT NULL DEFAULT true;

    -- Where the photo came from, per image, because that is how it is undone.
    -- Null until a photo has been cut out, so it is also the answer to "can
    -- this be restored?" — one fact in one place rather than a flag that can
    -- disagree with a path.
    --
    -- Nullable and with no default: an existing image has no original other
    -- than itself, and claiming otherwise would offer a Restore that swapped a
    -- photo for a copy of itself.
    ALTER TABLE item_images
      ADD COLUMN IF NOT EXISTS original_image_path TEXT;
  `);
};

exports.down = (pgm) => {
  pgm.sql(`
    ALTER TABLE item_drafts DROP COLUMN IF EXISTS remove_background;
    ALTER TABLE item_images DROP COLUMN IF EXISTS original_image_path;
  `);
};
  • Step 4: Update the Drizzle mirror

src/db-drizzle/schema.ts is generated by drizzle-kit pull, and the guard test drizzleSchema.integration.test.ts asserts it declares every column of every table — a column added to a table the mirror already knows about is the drift a table-level check waves through.

If the local dev database is up, re-pull:

cd backend && DRIZZLE_DATABASE_URL=postgres://redefined_local:redefined_local@localhost:55500/redefined_local npx drizzle-kit pull

Otherwise add the two lines by hand, matching the generated style exactly (tab indentation, double-quoted column names). In the itemImages block, after sortOrder:

	originalImagePath: text("original_image_path"),

In the itemDrafts block, after submitterNote:

	removeBackground: boolean("remove_background").default(true).notNull(),

boolean and text are already imported at the top of that file — do not add imports.

  • Step 5: Run the tests to verify they pass
cd backend && npm run test:integration -- backgroundRemoval drizzleSchema

Expected: PASS, both suites. The Drizzle guard is what proves the mirror is not stale.

  • Step 6: Commit
git add backend/migrations/1787600000000_add-background-removal.js backend/src/db-drizzle/schema.ts backend/tests/integration/backgroundRemoval.integration.test.ts
git commit -m "feat(intake): record the background-removal intent and the original path (#281)"

Task 2: The rembg client

Files:

  • Create: backend/src/intake/rembgClient.ts
  • Test: backend/tests/unit/rembgClient.test.ts (create)
  • Modify: docker-compose.qa.yml, docker-compose.prod.yml

Interfaces:

  • Consumes: trimTrailingSlashes from ../utils; signatureMatches, SIGNATURE_BYTES from ../uploadTypes.

  • Produces:

    • isRembgConfigured(): boolean
    • removeBackground(bytes: Buffer, mediaType: string): Promise<Buffer> — resolves with PNG bytes, rejects with an Error on every failure.
  • Step 1: Write the failing test

Create backend/tests/unit/rembgClient.test.ts:

import http from 'http';
import { AddressInfo } from 'net';
import { isRembgConfigured, removeBackground } from '../../src/intake/rembgClient';

/** A real PNG header, so the client's own signature check sees what it expects. */
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);

interface Capture {
  url: string;
  body: string;
}

/**
 * A stub sidecar on an ephemeral port.
 *
 * Port 0 rather than a fixed number: several ports in the 55000s are
 * Hyper-V-reserved on the development machine and bind with EACCES, and a
 * fixed port would also stop this suite running beside itself.
 *
 * A real HTTP server rather than a mocked `fetch`, because what is being
 * checked is the shape of the request that reaches the wire — above all that
 * `model=u2net` is in it.
 */
async function withStub(
  handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
  run: (capture: Capture) => Promise<void>
): Promise<void> {
  const capture: Capture = { url: '', body: '' };
  const server = http.createServer((req, res) => {
    capture.url = req.url ?? '';
    const chunks: Buffer[] = [];
    req.on('data', (c: Buffer) => chunks.push(c));
    req.on('end', () => {
      capture.body = Buffer.concat(chunks).toString('latin1');
      handler(req, res);
    });
  });

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  process.env.REMBG_URL = `http://127.0.0.1:${port}`;

  try {
    await run(capture);
  } finally {
    delete process.env.REMBG_URL;
    await new Promise<void>((resolve) => server.close(() => resolve()));
  }
}

function respondWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void {
  res.writeHead(200, { 'Content-Type': 'image/png' });
  res.end(PNG);
}

describe('whether the sidecar is configured', () => {
  it('is false when REMBG_URL is unset', () => {
    delete process.env.REMBG_URL;
    expect(isRembgConfigured()).toBe(false);
  });

  // A variable set to spaces is a configuration mistake, not a value — the
  // same reading envValidation applies everywhere else.
  it('is false when REMBG_URL is blank', () => {
    process.env.REMBG_URL = '   ';
    expect(isRembgConfigured()).toBe(false);
    delete process.env.REMBG_URL;
  });

  it('is true when REMBG_URL is set', () => {
    process.env.REMBG_URL = 'http://rembg-syn:7000';
    expect(isRembgConfigured()).toBe(true);
    delete process.env.REMBG_URL;
  });
});

describe('asking the sidecar to remove a background', () => {
  /**
   * The single most important assertion in this file.
   *
   * The image's default model is `bria-rmbg`, licensed non-commercial, and it
   * is selected by simply not naming a model. Nothing about the returned image
   * would reveal it had been used, so this is the only place it can be caught.
   */
  it('names u2net explicitly, because the default is licensed non-commercial', async () => {
    await withStub(respondWithPng, async (capture) => {
      await removeBackground(JPEG, 'image/jpeg');
      expect(capture.body).toContain('name="model"');
      expect(capture.body).toContain('u2net');
    });
  });

  it('posts the file to /api/remove and returns the PNG it gets back', async () => {
    await withStub(respondWithPng, async (capture) => {
      const out = await removeBackground(JPEG, 'image/jpeg');
      expect(capture.url).toBe('/api/remove');
      expect(capture.body).toContain('name="file"');
      expect(out.subarray(0, 8)).toEqual(PNG.subarray(0, 8));
    });
  });

  it('rejects rather than returning bytes when the sidecar errors', async () => {
    await withStub(
      (_req, res) => {
        res.writeHead(500);
        res.end('boom');
      },
      async () => {
        await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/500/);
      }
    );
  });

  // The failure that would otherwise write an HTML error page over a
  // photograph. Checked with the same magic-byte helper the upload path uses,
  // rather than by trusting the Content-Type the sidecar sent.
  it('rejects a response that is not actually a PNG', async () => {
    await withStub(
      (_req, res) => {
        res.writeHead(200, { 'Content-Type': 'image/png' });
        res.end('<html>not an image</html>');
      },
      async () => {
        await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/not a PNG/);
      }
    );
  });

  it('rejects when it is not configured at all', async () => {
    delete process.env.REMBG_URL;
    await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/REMBG_URL/);
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js tests/unit/rembgClient.test.ts

Expected: FAIL with Cannot find module '../../src/intake/rembgClient'.

  • Step 3: Write the implementation

Create backend/src/intake/rembgClient.ts:

import { trimTrailingSlashes } from '../utils';
import { SIGNATURE_BYTES, signatureMatches } from '../uploadTypes';

/**
 * The one place that talks to the background-removal sidecar.
 *
 * A sidecar rather than in-process inference: the application runs in a
 * container, and putting Python and ONNX into the image would add roughly
 * 300 MB to one already over a gigabyte. See
 * docs/ops/image-background-removal-stack.md for the measurements.
 */

/**
 * NEVER remove this, and never make it configurable.
 *
 * The sidecar's default model is `bria-rmbg`, and BRIA's RMBG models are
 * licensed for NON-COMMERCIAL use. This is a shop. The default is reached by
 * simply not naming a model, so it is a licensing problem that happens
 * silently and produces a perfectly good image — there is nothing in the
 * output that could reveal it.
 *
 * `u2net` is Apache-2.0, and also ten times faster (1.12.3 s against
 * 1420 s) at a sixth the size, so nothing is being traded away for it.
 */
const MODEL = 'u2net';

/**
 * Generous on purpose. The sidecar takes about 40 seconds to answer after a
 * container start and its first call per model downloads 168 MB, so a tight
 * timeout would turn an ordinary cold start into a failure. Nobody is waiting
 * on this in the worker's path, and an admin who clicked a button would rather
 * wait than be told it did not work.
 */
const TIMEOUT_MS = 120_000;

/** The configured base URL, or null when there is none. */
function baseUrl(): string | null {
  const raw = process.env.REMBG_URL;
  if (raw === undefined || raw.trim() === '') return null;
  return trimTrailingSlashes(raw.trim());
}

/**
 * Whether the feature exists in this environment.
 *
 * Unconfigured is not a failure. It means the submitter sees no checkbox, the
 * admin sees no control and the worker skips the step — an unconfigured
 * environment must be a working one, which is the same rule
 * `getAnthropicClient` follows by returning null rather than throwing.
 */
export function isRembgConfigured(): boolean {
  return baseUrl() !== null;
}

/**
 * The cut-out, as PNG bytes.
 *
 * Rejects on every failure — unconfigured, unreachable, a non-2xx answer, or a
 * body that is not actually a PNG. Every caller catches, and none of them lets
 * the rejection reach a submission or a draft.
 */
export async function removeBackground(bytes: Buffer, mediaType: string): Promise<Buffer> {
  const base = baseUrl();
  if (base === null) {
    throw new Error('REMBG_URL is not set');
  }

  const body = new FormData();
  // A copy through Uint8Array because Buffer is not a BlobPart. The filename is
  // a constant: the sidecar does not use it, and passing the stored name would
  // put a value from the uploads volume into an outbound request for nothing.
  body.append('file', new Blob([new Uint8Array(bytes)], { type: mediaType }), 'photo');
  body.append('model', MODEL);

  const res = await fetch(`${base}/api/remove`, {
    method: 'POST',
    body,
    signal: AbortSignal.timeout(TIMEOUT_MS)
  });

  if (!res.ok) {
    throw new Error(`rembg answered ${res.status}`);
  }

  const out = Buffer.from(await res.arrayBuffer());

  // The bytes, not the Content-Type header. A proxy error page served as
  // image/png would otherwise be written over a photograph — the same reason
  // uploads are checked by signature rather than by what the caller declared.
  if (!signatureMatches('image/png', out.subarray(0, SIGNATURE_BYTES))) {
    throw new Error('rembg did not return a PNG');
  }

  return out;
}
  • Step 4: Run the test to verify it passes
cd backend && npx jest -c jest.unit.config.js tests/unit/rembgClient.test.ts

Expected: PASS, 8 tests.

  • Step 5: Wire REMBG_URL into both compose files

In docker-compose.qa.yml, in the backend service's environment: list, immediately after the ANTHROPIC_WORKSPACE_ID line:

      # Optional. The background-removal sidecar (#281). Unset means the
      # feature does not exist: no checkbox on the submission page, no control
      # in the review queue, and the worker skips the step. Empty default so an
      # unset stack variable cannot fail a deploy.
      - REMBG_URL=${QA_REMBG_URL:-}

And in the header comment block, beside the other optional variables:

#   QA_REMBG_URL — optional. The background-removal sidecar, e.g.
#     http://rembg-syn:7000. Unset turns the feature off rather than breaking
#     anything. The sidecar must be on the same network as this stack.

In docker-compose.prod.yml, after its ANTHROPIC_WORKSPACE_ID line:

      # Optional. The background-removal sidecar (#281).
      # See docs/ops/image-background-removal-stack.md.
      - REMBG_URL=${REMBG_URL:-}

And in its header comment block:

#   REMBG_URL             Optional. The background-removal sidecar, e.g.
#                         http://rembg-syn:7000. Unset turns the feature off.

REMBG_URL is deliberately not added to ALWAYS_REQUIRED in envValidation.ts — it is optional, and requiring it would make an environment without a sidecar refuse to boot. The compose guard only enforces ALWAYS_REQUIRED, so no change is needed there either.

  • Step 6: Run the compose guard, lint and build
cd backend && npx jest -c jest.unit.config.js tests/unit/composeEnvironment.test.ts && npm run lint && npm run build

Expected: PASS, clean lint, clean build.

  • Step 7: Commit
git add backend/src/intake/rembgClient.ts backend/tests/unit/rembgClient.test.ts docker-compose.qa.yml docker-compose.prod.yml
git commit -m "feat(intake): talk to the rembg sidecar, always naming u2net (#281)"

Task 3: The shared swap and restore

Files:

  • Create: backend/src/intake/backgroundRemoval.ts
  • Test: backend/tests/unit/backgroundRemoval.test.ts (create), backend/tests/integration/backgroundRemoval.integration.test.ts (extend)

Interfaces:

  • Consumes: removeBackground, isRembgConfigured from ./rembgClient; typeForExtension from ../uploadTypes; pool from ../db.

  • Produces:

    • cutoutPathFor(imagePath: string): string
    • removeImageBackground(imageId: number): Promise<void>
    • restoreImageOriginal(imageId: number): Promise<void>
    • removeBackgroundsForItem(itemId: number): Promise<void>
  • Step 1: Write the failing unit test for the pure part

Create backend/tests/unit/backgroundRemoval.test.ts:

import { cutoutPathFor } from '../../src/intake/backgroundRemoval';

describe('where a cut-out is written', () => {
  // A new file rather than a rewrite of the original, which is what makes the
  // original restorable at all — and what makes the JPEG-to-PNG change free,
  // since no existing path is renamed.
  it('sits beside the original with a -cutout suffix and a .png extension', () => {
    expect(cutoutPathFor('/uploads/abc-123.jpg')).toBe('/uploads/abc-123-cutout.png');
  });

  // image_path is a contract, not a string: #103 made the stored value the
  // path uploadUrl joins an origin onto.
  it('keeps the /uploads/ prefix', () => {
    expect(cutoutPathFor('/uploads/x.webp')).toBe('/uploads/x-cutout.png');
  });

  // The extension is replaced rather than appended, so a second pass cannot
  // produce `.png.png`.
  it('replaces the extension rather than appending to it', () => {
    expect(cutoutPathFor('/uploads/x.png')).toBe('/uploads/x-cutout.png');
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js tests/unit/backgroundRemoval.test.ts

Expected: FAIL with Cannot find module '../../src/intake/backgroundRemoval'.

  • Step 3: Write the implementation

Create backend/src/intake/backgroundRemoval.ts:

import { promises as fs } from 'fs';
import path from 'path';
import { pool } from '../db';
import { typeForExtension } from '../uploadTypes';
import { isRembgConfigured, removeBackground } from './rembgClient';

/**
 * Swapping a photo for a cut-out of itself, and swapping it back.
 *
 * One module rather than two, because the worker's path and the admin's path
 * must produce identical results: a cut-out obtained either way has to be
 * undoable the same way. A near-copy that drifted would mean a photo the
 * Restore button could not restore.
 *
 * Nothing here deletes anything. The original file stays on disk and so does
 * every cut-out ever made, because the submitter's photos are often the only
 * copy of an item no longer in their hands — the same rule Discard follows in
 * the review queue.
 */

interface ImageRow {
  image_path: string;
  original_image_path: string | null;
}

/**
 * The path a cut-out of `imagePath` is written to.
 *
 * Pure, so the naming rule can be checked without a database or a sidecar.
 * Always `.png` because the result is transparent, and the storefront's dark
 * theme would show a flat white background as a bright box behind every
 * product.
 */
export function cutoutPathFor(imagePath: string): string {
  const base = path.basename(imagePath, path.extname(imagePath));
  return `/uploads/${base}-cutout.png`;
}

function uploadsDir(): string {
  return process.env.UPLOADS_DIR ?? '';
}

/**
 * Replaces one image with a cut-out, keeping the original.
 *
 * Idempotent by way of the `original_image_path IS NOT NULL` check rather than
 * a separate flag. That guard is load-bearing twice over: it makes a repeat
 * call a no-op, and it stops a second pass from recording the *cut-out* as the
 * original and losing the real one for good.
 *
 * Throws on every failure. Nothing is written to the row unless the file is
 * already on disk, so a caller that catches and moves on leaves the photo
 * exactly as it was.
 */
export async function removeImageBackground(imageId: number): Promise<void> {
  const { rows } = await pool.query<ImageRow>(
    `SELECT image_path, original_image_path FROM item_images WHERE id = $1`,
    [imageId]
  );
  const row = rows[0];
  if (!row) {
    throw new Error(`no image ${imageId}`);
  }
  if (row.original_image_path !== null) {
    // Already cut out. Doing it again would overwrite the record of where the
    // real original went.
    return;
  }

  // basename only: image_path is stored as '/uploads/<name>' and the directory
  // it lives in is a server constant. Same rule readPhotos follows in the
  // drafting worker.
  const sourceName = path.basename(row.image_path);
  const mediaType = typeForExtension(path.extname(sourceName));
  if (mediaType === null) {
    throw new Error(`cannot read ${sourceName}: unrecognised extension`);
  }

  const cutout = await removeBackground(
    await fs.readFile(path.join(uploadsDir(), sourceName)),
    mediaType
  );

  const cutoutPath = cutoutPathFor(row.image_path);
  await fs.writeFile(path.join(uploadsDir(), path.basename(cutoutPath)), cutout);

  // The row is pointed at the new file only after the file exists. The other
  // order would leave a window in which the storefront rendered a broken image.
  //
  // `original_image_path = image_path` reads the pre-update value, which is how
  // Postgres evaluates an UPDATE's right-hand side — so this records where the
  // photo came from in the same statement that moves it.
  await pool.query(
    `UPDATE item_images
     SET image_path = $2, original_image_path = image_path
     WHERE id = $1 AND original_image_path IS NULL`,
    [imageId, cutoutPath]
  );
}

/**
 * Puts the original back.
 *
 * The cut-out file is left on disk deliberately. Removing a background is
 * exactly the operation that produces an occasional bad result on an unusual
 * object, so somebody restoring one is quite likely to try again — and this
 * module deletes nothing in any case.
 */
export async function restoreImageOriginal(imageId: number): Promise<void> {
  const { rowCount } = await pool.query(
    `UPDATE item_images
     SET image_path = original_image_path, original_image_path = NULL
     WHERE id = $1 AND original_image_path IS NOT NULL`,
    [imageId]
  );
  if (rowCount === 0) {
    throw new Error(`image ${imageId} has no original to restore`);
  }
}

/**
 * Every photo of one item, in order.
 *
 * Sequential rather than parallel: the sidecar is assumed to handle one
 * request at a time, and the worker it runs inside is not in a hurry. A
 * failure on one photo stops the rest, and the caller logs it — the item keeps
 * whatever was already done, and nothing is left half-written.
 */
export async function removeBackgroundsForItem(itemId: number): Promise<void> {
  if (!isRembgConfigured()) return;

  const { rows } = await pool.query<{ id: number }>(
    `SELECT id FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
    [itemId]
  );
  for (const row of rows) {
    await removeImageBackground(row.id);
  }
}
  • Step 4: Run the unit test to verify it passes
cd backend && npx jest -c jest.unit.config.js tests/unit/backgroundRemoval.test.ts

Expected: PASS, 3 tests.

  • Step 5: Write the failing integration tests

In backend/tests/integration/backgroundRemoval.integration.test.ts, add these imports beside the existing ones:

import http from 'http';
import { AddressInfo } from 'net';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
import { removeImageBackground, restoreImageOriginal } from '../../src/intake/backgroundRemoval';

And append to the end of the file:

const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);

interface ImagePaths {
  image_path: string;
  original_image_path: string | null;
}

let uploads = '';
let stub: http.Server | null = null;

/**
 * A real uploads directory and a stub sidecar.
 *
 * A temporary directory rather than the configured one, because these tests
 * write files and a suite that leaves rubbish in a developer's uploads volume
 * is a suite people stop running.
 *
 * No test here contacts the real sidecar. It takes forty seconds to start, and
 * a suite that depends on that is broken by construction.
 */
async function startStub(
  handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
): Promise<void> {
  uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'bgremoval-'));
  process.env.UPLOADS_DIR = uploads;

  stub = http.createServer((req, res) => {
    // Drain the body before answering, or the client sees a reset rather than
    // the status this test is about.
    req.on('data', () => undefined);
    req.on('end', () => handler(req, res));
  });
  await new Promise<void>((resolve) => stub!.listen(0, '127.0.0.1', resolve));
  process.env.REMBG_URL = `http://127.0.0.1:${(stub!.address() as AddressInfo).port}`;
}

function answerWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void {
  res.writeHead(200, { 'Content-Type': 'image/png' });
  res.end(PNG_BYTES);
}

afterEach(async () => {
  delete process.env.REMBG_URL;
  if (stub) {
    await new Promise<void>((resolve) => stub!.close(() => resolve()));
    stub = null;
  }
});

async function seedWithFile(): Promise<{ itemId: number; imageId: number; name: string }> {
  const name = 'original.jpg';
  const seeded = await seedSubmission(`/uploads/${name}`);
  await fsp.writeFile(path.join(uploads, name), JPEG_BYTES);
  return { ...seeded, name };
}

async function pathsOf(imageId: number): Promise<ImagePaths> {
  const { rows } = await pool.query<ImagePaths>(
    `SELECT image_path, original_image_path FROM item_images WHERE id = $1`,
    [imageId]
  );
  return rows[0]!;
}

describe('removing one photos background', () => {
  it('points the row at the cut-out and records where the original went', async () => {
    await startStub(answerWithPng);
    const { imageId } = await seedWithFile();

    await removeImageBackground(imageId);

    const paths = await pathsOf(imageId);
    expect(paths.image_path).toBe('/uploads/original-cutout.png');
    expect(paths.original_image_path).toBe('/uploads/original.jpg');
  });

  // The original is the only copy of an item that may no longer be in the
  // sender's hands. Nothing in this module is allowed to remove it.
  it('leaves the original file on disk', async () => {
    await startStub(answerWithPng);
    const { imageId, name } = await seedWithFile();

    await removeImageBackground(imageId);

    await expect(fsp.access(path.join(uploads, name))).resolves.toBeUndefined();
  });

  it('writes the cut-out where the row now says it is', async () => {
    await startStub(answerWithPng);
    const { imageId } = await seedWithFile();

    await removeImageBackground(imageId);

    await expect(fsp.readFile(path.join(uploads, 'original-cutout.png'))).resolves.toEqual(
      PNG_BYTES
    );
  });

  // The guard that stops a second pass recording the cut-out as the original
  // and losing the real one for good.
  it('is a no-op on a photo that has already been cut out', async () => {
    await startStub(answerWithPng);
    const { imageId } = await seedWithFile();

    await removeImageBackground(imageId);
    await removeImageBackground(imageId);

    expect((await pathsOf(imageId)).original_image_path).toBe('/uploads/original.jpg');
  });
});

describe('when the sidecar will not answer', () => {
  it('leaves the row untouched on a 500', async () => {
    await startStub((_req, res) => {
      res.writeHead(500);
      res.end('boom');
    });
    const { imageId } = await seedWithFile();

    await expect(removeImageBackground(imageId)).rejects.toThrow(/500/);

    const paths = await pathsOf(imageId);
    expect(paths.image_path).toBe('/uploads/original.jpg');
    expect(paths.original_image_path).toBeNull();
  });

  it('leaves the row untouched when it returns something that is not an image', async () => {
    await startStub((_req, res) => {
      res.writeHead(200, { 'Content-Type': 'image/png' });
      res.end('<html>gateway error</html>');
    });
    const { imageId } = await seedWithFile();

    await expect(removeImageBackground(imageId)).rejects.toThrow(/not a PNG/);

    const paths = await pathsOf(imageId);
    expect(paths.image_path).toBe('/uploads/original.jpg');
    expect(paths.original_image_path).toBeNull();
  });

  it('leaves the row untouched when it is unreachable', async () => {
    await startStub(answerWithPng);
    // Closed before the call, so the connection is refused rather than hung.
    // The URL stays set, which is the case worth modelling: configured, and
    // not there.
    await new Promise<void>((resolve) => stub!.close(() => resolve()));
    stub = null;
    const { imageId } = await seedWithFile();

    await expect(removeImageBackground(imageId)).rejects.toThrow();

    const paths = await pathsOf(imageId);
    expect(paths.image_path).toBe('/uploads/original.jpg');
    expect(paths.original_image_path).toBeNull();
  });
});

describe('putting the original back', () => {
  it('swaps the paths back and clears the record', async () => {
    await startStub(answerWithPng);
    const { imageId } = await seedWithFile();
    await removeImageBackground(imageId);

    await restoreImageOriginal(imageId);

    const paths = await pathsOf(imageId);
    expect(paths.image_path).toBe('/uploads/original.jpg');
    expect(paths.original_image_path).toBeNull();
  });

  it('refuses a photo that was never cut out, rather than blanking its path', async () => {
    await startStub(answerWithPng);
    const { imageId } = await seedWithFile();

    await expect(restoreImageOriginal(imageId)).rejects.toThrow(/no original/);

    expect((await pathsOf(imageId)).image_path).toBe('/uploads/original.jpg');
  });
});
  • Step 6: Run the integration tests to verify they pass
cd backend && npm run test:integration -- backgroundRemoval

Expected: PASS, 11 tests.

  • Step 7: Commit
git add backend/src/intake/backgroundRemoval.ts backend/tests/unit/backgroundRemoval.test.ts backend/tests/integration/backgroundRemoval.integration.test.ts
git commit -m "feat(intake): swap a photo for a cut-out, keeping the original (#281)"

Task 4: The worker honours the submitter's intent

Files:

  • Modify: backend/src/intake/draftingWorker.ts
  • Test: backend/tests/integration/draftingBackgroundRemoval.integration.test.ts (create)

Interfaces:

  • Consumes: removeBackgroundsForItem from ./backgroundRemoval; item_drafts.remove_background from Task 1.
  • Produces: no new exports. draftQueued's signature and SweepResult are unchanged.

Why a new test file. These cases need a successful draft, and drafting.integration.test.ts has never produced one — every case in it either has no API key (and is skipped) or has no readable photo (and fails). It therefore has no mock for draftListing, and adding one there would be file-wide and would change what those existing tests exercise. A separate file keeps the mock's scope obvious.

  • Step 1: Write the failing test

Create backend/tests/integration/draftingBackgroundRemoval.integration.test.ts:

import http from 'http';
import { AddressInfo } from 'net';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { draftQueued } from '../../src/intake/draftingWorker';
import { resetAnthropicClient } from '../../src/intake/anthropicClient';
import { draftListing } from '../../src/intake/draftListing';

/**
 * The worker's background-removal step (#281).
 *
 * The model is mocked rather than reached. What is under test is what the
 * worker does *after* a draft is written — which of the two paths it takes,
 * and what survives when the sidecar does not answer — and none of that
 * depends on what the model said.
 */
jest.mock('../../src/intake/draftListing');

const draftListingMock = draftListing as jest.MockedFunction<typeof draftListing>;

const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);

let uploads = '';
let stub: http.Server | null = null;

beforeEach(async () => {
  await resetDb();

  draftListingMock.mockResolvedValue({
    draft: {
      name: 'Blue stoneware vase',
      description: 'Hand-thrown, chipped base.',
      category: null,
      tags: [],
      suggestedPriceCents: 4500
    },
    model: 'claude-sonnet-5',
    inputTokens: 1000,
    outputTokens: 200,
    costMicros: 4000
  });

  // Non-empty is all that is needed: getAnthropicClient only has to return
  // something other than null, and the mock above is what answers.
  process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used';
  resetAnthropicClient();

  uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'workerbg-'));
  process.env.UPLOADS_DIR = uploads;

  stub = http.createServer((req, res) => {
    req.on('data', () => undefined);
    req.on('end', () => {
      res.writeHead(200, { 'Content-Type': 'image/png' });
      res.end(PNG_BYTES);
    });
  });
  await new Promise<void>((resolve) => stub!.listen(0, '127.0.0.1', resolve));
  process.env.REMBG_URL = `http://127.0.0.1:${(stub!.address() as AddressInfo).port}`;
});

afterEach(async () => {
  delete process.env.REMBG_URL;
  delete process.env.ANTHROPIC_API_KEY;
  resetAnthropicClient();
  if (stub) {
    await new Promise<void>((resolve) => stub!.close(() => resolve()));
    stub = null;
  }
});

afterAll(async () => {
  await pool.end();
  await closeDb();
});

/** A queued submission with one real file on disk and the intent set. */
async function seedSubmissionWithPhoto(options: { removeBackground: boolean }): Promise<number> {
  const { rows } = await pool.query<{ id: number }>(
    `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
  );
  const itemId = rows[0]!.id;
  await pool.query(
    `INSERT INTO item_drafts (item_id, submitter_note, remove_background)
     VALUES ($1, 'a note', $2)`,
    [itemId, options.removeBackground]
  );
  await pool.query(
    `INSERT INTO item_images (item_id, image_path, sort_order)
     VALUES ($1, '/uploads/worker.jpg', 0)`,
    [itemId]
  );
  await fsp.writeFile(path.join(uploads, 'worker.jpg'), JPEG_BYTES);
  return itemId;
}

async function originalPathOf(itemId: number): Promise<string | null> {
  const { rows } = await pool.query<{ original_image_path: string | null }>(
    `SELECT original_image_path FROM item_images WHERE item_id = $1`,
    [itemId]
  );
  return rows[0]?.original_image_path ?? null;
}

describe('background removal after a draft', () => {
  // The mock has to actually be in play, or the two cases below would both
  // pass for the wrong reason — a draft that never happened cuts nothing out.
  it('drafts successfully, which is what the removal step follows', async () => {
    await seedSubmissionWithPhoto({ removeBackground: false });

    expect(await draftQueued(1)).toEqual({ drafted: 1, failed: 0, skipped: 0 });
  });

  // Recorded at submission and acted on here, so the sender never waits and a
  // sidecar that is down cannot fail their upload.
  it('cuts out the photos when the submitter asked for it', async () => {
    const itemId = await seedSubmissionWithPhoto({ removeBackground: true });

    await draftQueued(1);

    expect(await originalPathOf(itemId)).toBe('/uploads/worker.jpg');
  });

  it('leaves the photos alone when they did not', async () => {
    const itemId = await seedSubmissionWithPhoto({ removeBackground: false });

    await draftQueued(1);

    expect(await originalPathOf(itemId)).toBeNull();
  });

  // The governing rule: removal is a convenience on top of a draft that was
  // written correctly. A sidecar failure must never turn a good draft into a
  // failed one, because the queue is what the admin actually works from.
  it('leaves the draft ready when the sidecar fails', async () => {
    const itemId = await seedSubmissionWithPhoto({ removeBackground: true });
    // Configured, and nothing listening on it.
    process.env.REMBG_URL = 'http://127.0.0.1:1';

    await draftQueued(1);

    const { rows } = await pool.query<{ state: string }>(
      `SELECT state FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(rows[0]?.state).toBe('ready');
    expect(await originalPathOf(itemId)).toBeNull();
  });
});
  • Step 2: Run it to verify it fails
cd backend && npm run test:integration -- draftingBackgroundRemoval

Expected: FAIL — original_image_path is null in the first case, because nothing calls the removal yet.

  • Step 3: Wire it into the worker

In backend/src/intake/draftingWorker.ts, add the import beside the others:

import { removeBackgroundsForItem } from './backgroundRemoval';

Add the column to QueuedRow:

interface QueuedRow {
  item_id: number;
  submitter_note: string | null;
  remove_background: boolean;
}

Add it to the SELECT in draftQueued:

    `SELECT item_id, submitter_note, remove_background FROM item_drafts
     WHERE state = 'queued' AND attempts < $2
     ORDER BY created_at
     LIMIT $1`,

And inside the for (const row of rows) loop, immediately after drafted++; and before the notifyDraftReady call:

      // Deliberately after the draft is committed, and catching for itself.
      //
      // This is the sender's tick from the submission page, honoured here so
      // they never waited for it — and a failure must not mark a draft that was
      // written correctly as failed. The photo keeps its original in that case,
      // and the admin's per-photo control is still there to do it by hand.
      //
      // Awaited, unlike the notification below, so a sweep that has returned
      // has finished its work. Nothing is waiting on this: the worker is off
      // the request path, which is the whole reason drafting lives here.
      if (row.remove_background) {
        await removeBackgroundsForItem(row.item_id).catch((err) =>
          console.error(`[drafting] background removal for item ${row.item_id}:`, err)
        );
      }

Also extend the file's header comment, after the paragraph beginning "The governing rule is that a submission is the only irreplaceable thing here":

 * Background removal (#281) follows drafting rather than running on its own
 * pass. That couples the two: an environment with no ANTHROPIC_API_KEY drafts
 * nothing, so it cuts out nothing either. That is the intended trade  a
 * separate pass would re-attempt an unreachable sidecar on every sweep for a
 * row that is going to sit at 'queued' indefinitely  and the admin's per-photo
 * control in the review queue is the way to do it by hand meanwhile.
  • Step 4: Run the tests to verify they pass
cd backend && npm run test:integration -- drafting backgroundRemoval && npm run build && npm run lint

Expected: PASS, clean build, clean lint. That pattern runs the new file, the existing drafting suite and both backgroundRemoval suites — the existing one matters here, because adding a column to its SELECT is exactly the kind of change that breaks a neighbouring test quietly.

  • Step 5: Commit
git add backend/src/intake/draftingWorker.ts backend/tests/integration/draftingBackgroundRemoval.integration.test.ts
git commit -m "feat(intake): cut out backgrounds in the worker, never in the upload (#281)"

Task 5: The intake route records the intent

Files:

  • Modify: backend/src/routes/intake.ts
  • Test: backend/tests/integration/intake.integration.test.ts

Interfaces:

  • Consumes: isRembgConfigured from ../intake/rembgClient.

  • Produces:

    • GET /api/intake/:token{ label: string, backgroundRemoval: boolean }
    • POST /api/intake/:token accepts a removeBackground multipart field; only the exact string 'false' opts out.
  • Step 1: Write the failing test

Append to backend/tests/integration/intake.integration.test.ts. That file already has issueLink(label?, maxSubmissions?) and a module-level PNG fixture; it posts photos inline as request(app).post(...).attach('images', PNG, 'a.png'). Add one small helper beside issueLink so these cases can also send fields:

/** A submission with optional extra multipart fields beside the photo. */
function postPhoto(token: string, fields: Record<string, string> = {}) {
  const req = request(app).post(`/api/intake/${token}`);
  for (const [name, value] of Object.entries(fields)) void req.field(name, value);
  return req.attach('images', PNG, 'a.png');
}
describe('the background-removal intent', () => {
  // Ticked by default on the page, so absent means yes. An older client or a
  // curl call then behaves like the current default rather than silently
  // opting out of something every other submission gets.
  it('defaults to true when the field is not sent', async () => {
    const token = await issueLink();
    await postPhoto(token);

    const { rows } = await pool.query<{ remove_background: boolean }>(
      `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1`
    );
    expect(rows[0]?.remove_background).toBe(true);
  });

  it('records a submitter who unticked it', async () => {
    const token = await issueLink();
    await postPhoto(token, { removeBackground: 'false' });

    const { rows } = await pool.query<{ remove_background: boolean }>(
      `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1`
    );
    expect(rows[0]?.remove_background).toBe(false);
  });

  // Only the exact string opts out. A stray value is not a considered "no",
  // and reading it as one would quietly deny somebody something they asked for.
  it('treats anything other than "false" as consent', async () => {
    const token = await issueLink();
    await postPhoto(token, { removeBackground: 'no' });

    const { rows } = await pool.query<{ remove_background: boolean }>(
      `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1`
    );
    expect(rows[0]?.remove_background).toBe(true);
  });
});

describe('what the submission page is told', () => {
  it('says the feature is off when there is no sidecar', async () => {
    delete process.env.REMBG_URL;
    const token = await issueLink();

    const res = await request(app).get(`/api/intake/${token}`);

    expect(res.status).toBe(200);
    expect(res.body.backgroundRemoval).toBe(false);
  });

  it('says it is on when there is one', async () => {
    process.env.REMBG_URL = 'http://rembg-syn:7000';
    const token = await issueLink();

    const res = await request(app).get(`/api/intake/${token}`);

    expect(res.body.backgroundRemoval).toBe(true);
    delete process.env.REMBG_URL;
  });
});
  • Step 2: Run it to verify it fails
cd backend && npm run test:integration -- intake.integration

Expected: FAIL — backgroundRemoval is undefined on the GET, and the unticked case records true.

  • Step 3: Write the implementation

In backend/src/routes/intake.ts, add the import beside the others:

import { isRembgConfigured } from '../intake/rembgClient';

Change the GET handler's response:

  // The label, and whether the background-removal control has anything behind
  // it. Still nothing about the catalogue, the admin, or other links.
  res.json({ label: link.label, backgroundRemoval: isRembgConfigured() });

In the POST handler, immediately after the note line:

    // Absent means yes: the checkbox on the page is ticked by default, so a
    // client that does not send the field — an older build, or a script — gets
    // what every other submission gets rather than silently opting out.
    //
    // Only the exact string opts out. Multipart fields arrive as strings, and
    // reading a stray value as "no" would quietly deny somebody something they
    // asked for.
    const removeBackground = req.body?.removeBackground !== 'false';

And extend the item_drafts insert:

      await client.query(
        `INSERT INTO item_drafts (item_id, upload_link_id, submitter_note, remove_background)
         VALUES ($1, $2, $3, $4)`,
        [itemId, link.id, note === '' ? null : note, removeBackground]
      );

Nothing else in this route changes. The AI is still not called here, no bytes are sent to the sidecar here, and the ordering of requireUsableLink and requireCapacity ahead of uploadImages is untouched — that ordering is the whole mitigation that keeps a refused submission from writing a byte to disk.

  • Step 4: Run the tests to verify they pass
cd backend && npm run test:integration -- intake.integration && npx jest -c jest.unit.config.js tests/unit/routesAreWrapped.test.ts

Expected: PASS. The wrapper guard confirms no handler was added unwrapped.

  • Step 5: Commit
git add backend/src/routes/intake.ts backend/tests/integration/intake.integration.test.ts
git commit -m "feat(intake): record whether the submitter asked for a cut-out (#281)"

Task 6: The admin endpoints

Files:

  • Modify: backend/src/routes/adminItemDrafts.ts
  • Test: backend/tests/integration/adminItemDrafts.integration.test.ts

Interfaces:

  • Consumes: removeImageBackground, restoreImageOriginal from ../intake/backgroundRemoval; isRembgConfigured from ../intake/rembgClient.

  • Produces:

    • GET /api/admin/item-drafts{ drafts: Draft[], backgroundRemoval: boolean } (the response shape changes — Task 8 updates the client)
    • each entry of draft.images gains original_image_path: string | null
    • POST /api/admin/item-drafts/:itemId/images/:imageId/remove-background200 { image_path, original_image_path } | 404 | 502
    • POST /api/admin/item-drafts/:itemId/images/:imageId/restore-original200 { image_path, original_image_path } | 404
  • Step 1: Write the failing test

Append to backend/tests/integration/adminItemDrafts.integration.test.ts. That file calls request(app) directly — there is no authenticated-request helper, and these routes sit behind the same admin gate as the ones already tested there, so nothing extra is needed.

Its existing seedDraft returns only an item id and writes its photo at /uploads/a.jpg, so this block needs its own seed that returns the image id too and uses a name matching the cut-out path being asserted.

describe('the review queues background-removal control', () => {
  const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
  const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);

  let uploads = '';
  let stub: http.Server | null = null;

  /** A stub sidecar on an ephemeral port, and a temporary uploads directory. */
  async function startStub(status: number, body: Buffer | string): Promise<void> {
    uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'adminbg-'));
    process.env.UPLOADS_DIR = uploads;

    stub = http.createServer((req, res) => {
      req.on('data', () => undefined);
      req.on('end', () => {
        res.writeHead(status, { 'Content-Type': 'image/png' });
        res.end(body);
      });
    });
    await new Promise<void>((resolve) => stub!.listen(0, '127.0.0.1', resolve));
    process.env.REMBG_URL = `http://127.0.0.1:${(stub!.address() as AddressInfo).port}`;
  }

  afterEach(async () => {
    delete process.env.REMBG_URL;
    if (stub) {
      await new Promise<void>((resolve) => stub!.close(() => resolve()));
      stub = null;
    }
  });

  /** A ready draft with one photo, on disk, named to match the assertions. */
  async function seedDraftWithImage(): Promise<{ itemId: number; imageId: number }> {
    const { rows } = await pool.query<{ id: number }>(
      `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
    );
    const itemId = rows[0]!.id;
    await pool.query(`INSERT INTO item_drafts (item_id, state) VALUES ($1, 'ready')`, [itemId]);
    const image = await pool.query<{ id: number }>(
      `INSERT INTO item_images (item_id, image_path, sort_order)
       VALUES ($1, '/uploads/original.jpg', 0) RETURNING id`,
      [itemId]
    );
    if (uploads !== '') await fsp.writeFile(path.join(uploads, 'original.jpg'), JPEG_BYTES);
    return { itemId, imageId: image.rows[0]!.id };
  }

  it('says whether there is a sidecar behind the control at all', async () => {
    process.env.REMBG_URL = 'http://rembg-syn:7000';
    const on = await request(app).get('/api/admin/item-drafts');
    expect(on.body.backgroundRemoval).toBe(true);

    delete process.env.REMBG_URL;
    const off = await request(app).get('/api/admin/item-drafts');
    expect(off.body.backgroundRemoval).toBe(false);
  });

  // The UI decides between "Remove background" and "Restore original" from
  // this field alone, so it has to be in the payload the queue is built from.
  it('includes original_image_path on every image', async () => {
    await seedDraftWithImage();

    const res = await request(app).get('/api/admin/item-drafts');

    expect(res.body.drafts[0].images[0]).toHaveProperty('original_image_path', null);
  });

  it('cuts out one photo and answers with its new paths', async () => {
    await startStub(200, PNG_BYTES);
    const { itemId, imageId } = await seedDraftWithImage();

    const res = await request(app).post(
      `/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
    );

    expect(res.status).toBe(200);
    expect(res.body.image_path).toBe('/uploads/original-cutout.png');
    expect(res.body.original_image_path).toBe('/uploads/original.jpg');
  });

  it('puts the original back', async () => {
    await startStub(200, PNG_BYTES);
    const { itemId, imageId } = await seedDraftWithImage();
    await request(app).post(
      `/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
    );

    const res = await request(app).post(
      `/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`
    );

    expect(res.status).toBe(200);
    expect(res.body.image_path).toBe('/uploads/original.jpg');
    expect(res.body.original_image_path).toBeNull();
  });

  // 502 rather than 500: the request was fine and the app is fine, and saying
  // which of the two failed is what stops somebody searching the application
  // logs for a fault that is not there.
  it('answers 502 when the sidecar will not, and leaves the photo alone', async () => {
    await startStub(500, 'boom');
    const { itemId, imageId } = await seedDraftWithImage();

    const res = await request(app).post(
      `/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
    );

    expect(res.status).toBe(502);
    const { rows } = await pool.query<{ image_path: string }>(
      `SELECT image_path FROM item_images WHERE id = $1`,
      [imageId]
    );
    expect(rows[0]?.image_path).toBe('/uploads/original.jpg');
  });

  // Scoped by item as well as by image. The id is a serial, so guessing one is
  // not hard, and a photo from another submission must not be reachable
  // through this item's URL.
  it('refuses an image that does not belong to the item', async () => {
    await startStub(200, PNG_BYTES);
    const first = await seedDraftWithImage();
    const second = await seedDraftWithImage();

    const res = await request(app).post(
      `/api/admin/item-drafts/${first.itemId}/images/${second.imageId}/remove-background`
    );

    expect(res.status).toBe(404);
  });

  it('refuses to restore a photo that was never cut out', async () => {
    const { itemId, imageId } = await seedDraftWithImage();

    const res = await request(app).post(
      `/api/admin/item-drafts/${itemId}/images/${imageId}/restore-original`
    );

    expect(res.status).toBe(404);
  });
});

Add the imports this needs at the top of that file, beside its existing ones:

import http from 'http';
import { AddressInfo } from 'net';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';

Note that the second seeded item in the ownership case overwrites the same original.jpg, which is harmless — that case never reaches the file.

  • Step 2: Run it to verify it fails
cd backend && npm run test:integration -- adminItemDrafts

Expected: FAIL — backgroundRemoval undefined and both POSTs 404.

  • Step 3: Write the implementation

In backend/src/routes/adminItemDrafts.ts, add the imports:

import { removeImageBackground, restoreImageOriginal } from '../intake/backgroundRemoval';
import { isRembgConfigured } from '../intake/rembgClient';

In DRAFT_SELECT, extend the images aggregate to carry the restorability:

         COALESCE((
           SELECT json_agg(json_build_object(
                    'id', img.id,
                    'image_path', img.image_path,
                    'original_image_path', img.original_image_path)
                           ORDER BY img.sort_order)
           FROM item_images img WHERE img.item_id = d.item_id
         ), '[]'::json) AS images

Change the GET handler's response:

    // Whether the control has anything behind it, alongside the rows. A second
    // endpoint for one boolean would be a round trip the queue already makes.
    res.json({ drafts: rows, backgroundRemoval: isRembgConfigured() });

Then add, before export default router;:

/**
 * One photo's current paths, if it belongs to this item.
 *
 * Scoped by item as well as by image so an image id from a different
 * submission cannot be acted on through this item's URL — the id is a serial,
 * so guessing one is not hard.
 */
async function imageOfItem(
  itemId: string,
  imageId: string
): Promise<{ image_path: string; original_image_path: string | null } | null> {
  const { rows } = await pool.query<{ image_path: string; original_image_path: string | null }>(
    `SELECT image_path, original_image_path
     FROM item_images WHERE id = $1 AND item_id = $2`,
    [imageId, itemId]
  );
  return rows[0] ?? null;
}

/**
 * Remove the background from one photo.
 *
 * The other half of the submitter's checkbox: for the photos nobody ticked it
 * for, and for the ones where the worker could not reach the sidecar. Both go
 * through the same module, so a cut-out obtained either way is identical and
 * either can be undone by Restore.
 *
 * Synchronous, unlike the worker's path. A warm request measures 1.12.3 s and
 * this is an admin who just clicked a button and is watching for the result.
 * The reason drafting was moved off the request path — that a stranger can
 * trigger it and must never wait — does not apply behind the admin gate.
 */
router.post(
  '/:itemId/images/:imageId/remove-background',
  asyncRoute(async (req: Request, res: Response) => {
    const { itemId = '', imageId = '' } = req.params;
    if ((await imageOfItem(itemId, imageId)) === null) {
      return res.status(404).json({ error: 'no such photo on this item' });
    }

    try {
      await removeImageBackground(Number(imageId));
    } catch (err) {
      // 502, not 500. The request was fine and so is this app — the service it
      // depends on did not answer. The message says the photo is unchanged,
      // because that is the thing the admin actually needs to know.
      console.error(`[drafts] background removal for image ${imageId}:`, err);
      return res
        .status(502)
        .json({ error: 'the background-removal service did not answer — the photo is unchanged' });
    }

    res.json(await imageOfItem(itemId, imageId));
  })
);

/**
 * Put the original photo back.
 *
 * The reason a cut-out is safe to try at all. Background removal produces the
 * occasional poor result on an unusual object, and this makes that survivable
 * rather than something to prevent. Nothing is deleted: the cut-out file stays
 * on disk, because somebody restoring one is quite likely to try again.
 */
router.post(
  '/:itemId/images/:imageId/restore-original',
  asyncRoute(async (req: Request, res: Response) => {
    const { itemId = '', imageId = '' } = req.params;
    const existing = await imageOfItem(itemId, imageId);
    if (existing === null || existing.original_image_path === null) {
      return res.status(404).json({ error: 'this photo has no original to restore' });
    }

    await restoreImageOriginal(Number(imageId));
    res.json(await imageOfItem(itemId, imageId));
  })
);
  • Step 4: Run the tests to verify they pass
cd backend && npm run test:integration -- adminItemDrafts && npx jest -c jest.unit.config.js tests/unit/routesAreWrapped.test.ts && npm run build && npm run lint

Expected: PASS, clean build, clean lint.

  • Step 5: Commit
git add backend/src/routes/adminItemDrafts.ts backend/tests/integration/adminItemDrafts.integration.test.ts
git commit -m "feat(admin): remove and restore a photo's background per image (#281)"

Task 7: The submitter's checkbox

Files:

  • Modify: frontend/src/intake/intakeApi.ts, frontend/src/intake/Submit.tsx, scripts/start-local.ps1
  • Test: frontend/tests/e2e/intake-submit.spec.ts

Interfaces:

  • Consumes: GET /api/intake/:token{ label, backgroundRemoval } and the removeBackground field from Task 5.

  • Produces: IntakeLink gains backgroundRemoval: boolean; submitItem(token, files, note, removeBackground)a fourth required parameter.

  • Step 1: Update the API client

In frontend/src/intake/intakeApi.ts, extend the interface:

export interface IntakeLink {
  label: string;
  /**
   * Whether there is a background-removal sidecar behind the checkbox. False
   * hides it entirely rather than showing a control that would do nothing —
   * an unconfigured environment is a working one, not a broken one.
   */
  backgroundRemoval: boolean;
}

And the submit function's signature and body, leaving the rest of it unchanged:

export async function submitItem(
  token: string,
  files: File[],
  note: string,
  removeBackground: boolean
): Promise<SubmitResult> {
  const body = new FormData();
  // The field name the server's multer instance listens on. Sending several
  // under one name is what makes req.files an array.
  for (const file of files) body.append('images', file);
  body.append('note', note);
  // A string, because that is all a multipart field can be. The server treats
  // only the exact 'false' as an opt-out, so this is the one value that has to
  // be got right.
  body.append('removeBackground', removeBackground ? 'true' : 'false');
  • Step 2: Add the checkbox to the page

In frontend/src/intake/Submit.tsx, add the import with the other antd ones:

import Checkbox from 'antd/es/checkbox';

Add the state beside note:

  // Ticked by default. Most items look better cut out, and a submitter who
  // wants their kitchen table in the photograph can say so — the reverse
  // default would mean almost nobody got it.
  const [removeBackground, setRemoveBackground] = useState(true);

Pass it in send:

    const result = await submitItem(
      token,
      files.flatMap((f) => (f.originFileObj ? [f.originFileObj] : [])),
      note,
      removeBackground
    );

Reset it in the "Send another item" handler, beside setNote(''):

              setRemoveBackground(true);

And render it between the TextArea and the error Alert, inside the Space:

          {state.kind === 'usable' && state.link.backgroundRemoval && (
            <Checkbox
              checked={removeBackground}
              onChange={(e) => setRemoveBackground(e.target.checked)}
            >
              {/* Described by what it does, not by how. Nobody sending in a
                  vase knows what a cut-out or an alpha channel is. */}
              Remove the background from my photos
            </Checkbox>
          )}
  • Step 3: Set REMBG_URL for the local stack

The two e2e cases below only pass when the backend has REMBG_URL set — the checkbox's visibility depends on nothing else. Add it to the backend environment in scripts/start-local.ps1, beside the other optional variables it sets, pointing at a value that need not resolve: no e2e submission reaches the sidecar, because the worker only cuts out after a draft and drafting is not configured locally.

$env:REMBG_URL = 'http://127.0.0.1:7000'

Read that script and follow its existing style before editing. Never run it non-interactively — it prompts for elevation and can strip Node from the machine. Ask the user to run it.

  • Step 4: Write the failing e2e test

Append to frontend/tests/e2e/intake-submit.spec.ts, inside the existing test.describe:

  // Ticked by default, because that is the decision: most items look better
  // cut out, and the reverse default would mean almost nobody got it.
  test('offers to remove the background, already ticked', async ({ page }) => {
    await page.goto(`/submit/${token}`);

    const control = page.getByRole('checkbox', { name: /remove the background/i });
    await expect(control).toBeVisible();
    await expect(control).toBeChecked();
  });

  test('lets a sender turn it off and still send', async ({ page }) => {
    await page.goto(`/submit/${token}`);

    const png = Buffer.from(
      'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
      'base64'
    );
    await page.setInputFiles('input[type="file"]', {
      name: `${RUN}-nobg.png`,
      mimeType: 'image/png',
      buffer: png
    });
    await page.getByRole('checkbox', { name: /remove the background/i }).uncheck();
    await page.getByRole('button', { name: 'Send' }).click();

    await expect(page.getByRole('heading', { name: 'Thank you — it arrived' })).toBeVisible();
  });
  • Step 5: Verify the frontend builds and its unit tests pass
cd frontend && npm run build && npm test

npm run build, not npx tsc --noEmit: the app tsconfig excludes tests/, and a green bare tsc once broke a deploy. Any call site of submitItem that was not updated fails here, which is the point of making the fourth parameter required.

Expected: clean build, unit tests pass.

  • Step 6: Run the e2e spec

With the local stack running (ask the user to start it):

cd frontend && npx playwright test intake-submit --project=chromium

Expected: PASS, 6 tests.

  • Step 7: Commit
git add frontend/src/intake/intakeApi.ts frontend/src/intake/Submit.tsx frontend/tests/e2e/intake-submit.spec.ts scripts/start-local.ps1
git commit -m "feat(intake): offer background removal on the submission page, ticked (#281)"

Task 8: The admin's per-photo control, and the docs

Files:

  • Modify: frontend/src/admin/draftsApi.ts, frontend/src/admin/DraftQueue.tsx
  • Modify: docs/ops/image-background-removal-stack.md
  • Test: frontend/tests/e2e/admin-draft-queue.spec.ts

Interfaces:

  • Consumes: the endpoints and response shapes from Task 6.

  • Produces: DraftImage gains original_image_path: string | null; fetchDrafts returns DraftQueueResponsea changed return type; setImageBackground(itemId, imageId, action).

  • Step 1: Update the API client

In frontend/src/admin/draftsApi.ts:

export interface DraftImage {
  id: number;
  image_path: string;
  /**
   * Where this photo came from, once it has been cut out. Null means it never
   * was — which is also the answer to whether Restore has anything to do, so
   * there is no second flag that could disagree with it.
   */
  original_image_path: string | null;
}

Change fetchDrafts and add the action:

export interface DraftQueueResponse {
  drafts: Draft[];
  /**
   * Whether a background-removal sidecar is configured. False hides the
   * control rather than showing one that would answer 502 — an environment
   * without a sidecar is a working environment.
   */
  backgroundRemoval: boolean;
}

export async function fetchDrafts(state?: string): Promise<DraftQueueResponse> {
  const query = state ? `?state=${encodeURIComponent(state)}` : '';
  const res = await send(query);
  if (!res.ok) throw new Error('could not load the review queue');
  return res.json();
}

/**
 * Cut one photo out, or put its original back.
 *
 * The server's message is preferred over a generic one for the same reason
 * publishDraft prefers it: a 502 here says the removal service did not answer
 * and the photo is unchanged, which is the difference between "try again" and
 * "something is wrong with this item".
 */
export async function setImageBackground(
  itemId: number,
  imageId: number,
  action: 'remove-background' | 'restore-original'
): Promise<void> {
  const res = await send(`/${itemId}/images/${imageId}/${action}`, { method: 'POST' });
  if (res.ok) return;

  let message = 'could not change this photo';
  try {
    message = (await res.json()).error ?? message;
  } catch {
    // A non-JSON body is a proxy or gateway error rather than the app
    // refusing. The generic message is the honest thing to show.
  }
  throw new Error(message);
}
  • Step 2: Add the control to the queue

In frontend/src/admin/DraftQueue.tsx, extend the import:

import {
  Draft,
  DraftImage,
  PriceSource,
  actOnDraft,
  fetchDrafts,
  publishDraft,
  setImageBackground
} from './draftsApi';

Add a component above DraftCard:

/**
 * One photo, with the control that cuts it out or puts it back.
 *
 * Per photo rather than per item because that is how a poor result is undone:
 * background removal produces the occasional bad cut on an unusual object, and
 * the answer is to restore that one photograph, not to unpick the submission.
 *
 * The label is the state. `original_image_path` is the only thing consulted,
 * so there is no second flag that could disagree with what the button does.
 */
function DraftPhoto({
  image,
  itemId,
  enabled,
  onChanged
}: Readonly<{
  image: DraftImage;
  itemId: number;
  enabled: boolean;
  onChanged: () => void;
}>) {
  const [busy, setBusy] = useState(false);
  const cutOut = image.original_image_path !== null;

  const act = async () => {
    setBusy(true);
    try {
      await setImageBackground(itemId, image.id, cutOut ? 'restore-original' : 'remove-background');
      onChanged();
    } catch (err) {
      message.error(err instanceof Error ? err.message : 'that did not work');
    } finally {
      setBusy(false);
    }
  };

  return (
    <Space direction="vertical" size={4} align="center">
      <img src={image.image_path} alt="" style={{ width: 120, height: 120, objectFit: 'cover' }} />
      {enabled && (
        <Button size="small" loading={busy} onClick={() => void act()}>
          {cutOut ? 'Restore original' : 'Remove background'}
        </Button>
      )}
    </Space>
  );
}

Give DraftCard the flag:

function DraftCard({
  draft,
  backgroundRemoval,
  onChanged
}: Readonly<{ draft: Draft; backgroundRemoval: boolean; onChanged: () => void }>) {

and replace its draft.images.map(...) block with:

        <Space wrap align="start">
          {draft.images.map((image) => (
            <DraftPhoto
              key={image.id}
              image={image}
              itemId={draft.item_id}
              enabled={backgroundRemoval}
              onChanged={onChanged}
            />
          ))}
        </Space>

In DraftQueue, hold the flag and pass it down:

  const [drafts, setDrafts] = useState<Draft[]>([]);
  const [backgroundRemoval, setBackgroundRemoval] = useState(false);
  const load = useCallback(async () => {
    try {
      const payload = await fetchDrafts(state);
      setDrafts(payload.drafts);
      setBackgroundRemoval(payload.backgroundRemoval);
      setError(null);
    } catch {
      setError('Could not load the review queue.');
    }
  }, [state]);
      {drafts.map((draft) => (
        <DraftCard
          key={draft.item_id}
          draft={draft}
          backgroundRemoval={backgroundRemoval}
          onChanged={() => void load()}
        />
      ))}
  • Step 3: Write the failing e2e test

Append to the existing test.describe('The review queue', ...) block in frontend/tests/e2e/admin-draft-queue.spec.ts. That file already has submitAnItem(page, note), which seeds through the real intake route, and its tests take the { page, admin } fixtures and call admin.open('Review queue').

Scope the assertion to the card this test created, by the sender's note — the suite is fullyParallel against a dev database that never truncates, so a queue-wide locator outruns its timeout and fails for reasons unrelated to the behaviour under test (#241).

  // The control that makes a poor cut survivable. Its label is its state:
  // "Remove background" until an original has been recorded, "Restore
  // original" afterwards, read from one field rather than two that could
  // disagree.
  //
  // Only the label is asserted, not a click. Pressing it would need a sidecar,
  // and a test that depends on a service taking forty seconds to start is
  // broken by construction — the swap itself is covered in the integration
  // suite against a stub.
  test('offers to remove the background on each photo', async ({ page, admin }) => {
    const note = `Cutout ${RUN}`;
    await submitAnItem(page, note);

    await admin.open('Review queue');

    const card = page.locator('.ant-card').filter({ hasText: note });
    await expect(card.getByRole('button', { name: 'Remove background' })).toBeVisible();
  });

This passes only when the backend has REMBG_URL set, which Task 7 added to scripts/start-local.ps1.

  • Step 4: Update the ops document

docs/ops/image-background-removal-stack.md still says "Status: evaluated, not adopted" and closes with an "If this is adopted" section. Both are now wrong. Replace the status paragraph near the top with:

**Status: adopted (#281).** The engine choice is settled — see "The one thing
that must not be got wrong" below. Everything here was run and measured on
2026-09-02 against `danielgatis/rembg:latest`, on a Windows dev box under Docker
Desktop. **The NAS is a different machine and will be slower**; treat these as
an upper bound on capability, not a promise.

And replace the whole "If this is adopted" section at the end with:

## How the application uses it

`backend/src/intake/rembgClient.ts` is the only thing that talks to the
sidecar. It posts to `/api/remove` and **always sends `model=u2net`**; a unit
test asserts that parameter is present, because nothing about the returned
image would reveal its absence.

`REMBG_URL` points at it — `http://rembg-syn:7000` on the NAS, where the
container publishes `32700:7000`. The variable is optional in both compose
files: unset means the submission page shows no checkbox, the review queue
shows no control, and the drafting worker skips the step. An environment
without a sidecar is a working environment.

Two entry points, one module (`backend/src/intake/backgroundRemoval.ts`):

- **The drafting worker**, honouring the checkbox on `/submit/:token`, which is
  ticked by default. The submitter's tick is recorded and acted on later, so
  nobody waits on a model run and an unreachable sidecar cannot fail an upload.
- **The review queue**, per photo, synchronously — 1.12.3 s warm is a wait an
  admin who just clicked a button can absorb.

Because removal follows drafting in the worker, an environment with no
`ANTHROPIC_API_KEY` drafts nothing and so cuts out nothing. The per-photo
control in the review queue is the way to do it by hand there.

The original file is never destroyed. `item_images.original_image_path` records
where it went, and **Restore original** swaps it back. Nothing in the feature
deletes a file or a row.
  • Step 5: Verify everything
cd frontend && npm run build && npm run lint && npm test

Then the full suites, with the stack up (ask the user to start it):

cd backend && npm run test:unit && npm run test:integration
cd frontend && npx playwright test --project=chromium

Expected: all green. The e2e suite was 157/157 before this change; it should now be 160.

  • Step 6: Commit
git add frontend/src/admin/draftsApi.ts frontend/src/admin/DraftQueue.tsx frontend/tests/e2e/admin-draft-queue.spec.ts docs/ops/image-background-removal-stack.md
git commit -m "feat(admin): remove or restore a photo's background from the review queue (#281)"

After the plan

  • The branch is feature/281-background-removal. Do not push — the user pushes and merges.
  • The PR body closes #281 and should say plainly what is not established: quality on a real photograph, and behaviour under concurrent requests.
  • Per standing practice, follow this with a separate SonarQube cleanup issue and PR — hotspots, duplication, debt, coverage — never folded into this branch.
  • QA needs QA_REMBG_URL=http://rembg-syn:7000 set in the Portainer stack, and the rembg-syn service on the same network as the backend, before any of this does anything there.