Files
redefined-designs/docs/superpowers/plans/2026-08-29-intake-upload-links.md
T
bermudalamb 783d10abc4 docs(intake): plan the upload-link and submission-page slice (#220)
The first of four slices from the intake design, and the only one that is worth planning in detail yet — the later slices' shape depends on what this one actually produces.

Seven tasks: the schema, extracting the validated image-upload pipeline out of routes/admin.ts so the public endpoint reuses it rather than growing a near-copy of it, token generation and hashing, the admin API for issuing and revoking links, the public submission endpoint, the submission page, and the admin screen.

Three things the plan settles that the design left open or got wrong. The image caps become the constants already in the codebase rather than the 10-photo and 10 MB figures the design invented, because two different caps on one pipeline is a defect waiting to happen. The feature flag is dropped from this slice: nothing is reachable until a link exists, and the flag earns its keep in slice 2 where a paid API call appears. And the link is resolved before multer runs, so a stranger holding a bad token cannot cause a byte to be written to the uploads volume — cleanup afterwards would leave an unauthenticated caller in control of disk churn, and leans on an unlink that a crash between write and delete would skip. That ordering is asserted by a test, so a later reordering fails loudly rather than silently.

Ref #220
2026-08-29 08:09:39 -05:00

52 KiB

Intake — Upload Links and Submission Page 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: Someone holding a named, revocable link can send in photos of one item plus a note, and it arrives in the admin inventory as a pending item.

Architecture: A new upload_links table holds hashed tokens the admin issues and revokes. A public, rate-limited endpoint accepts a multipart submission through the existing validated image-upload pipeline — extracted from routes/admin.ts into a shared module rather than duplicated — and writes an items row at status='pending' with its images and an item_drafts row carrying the note and provenance. No AI and no email in this slice: the submission lands in the inventory screen that already exists.

Tech Stack: Express 4 + TypeScript, Postgres via pg, node-pg-migrate, multer, express-rate-limit, Jest + supertest (backend), React + antd + Vite, Vitest + Playwright (frontend).

Spec: docs/superpowers/specs/2026-08-29-intake-pipeline-design.md

Global Constraints

  • Node 20+ required for any npm run migrate:* command. node-pg-migrate pulls an lru-cache calling diagnostics_channel.tracingChannel(), absent before Node 19.9; on Node 18 it dies inside minified library code. Use scripts/start-local.ps1, which handles the version switch.
  • Every route handler must be wrapped in asyncRoute. tests/unit/routesAreWrapped.test.ts enforces this by scanning source and will fail the build otherwise. Express 4 does not forward rejected promises; an unwrapped async handler hangs the request forever.
  • antd imports are deep ESM pathsimport Button from 'antd/es/button'. Never import { Button } from 'antd'. Note this repo uses antd/es/, not antd/lib/.
  • Uploaded files must go through the existing validation — allowlist of image/jpeg, image/png, image/webp; magic-byte check after write; stored filename from randomUUID() plus an extension derived from the validated type, never from originalname.
  • Image caps are the existing constants, not new ones: MAX_IMAGES_PER_REQUEST = 6, MAX_IMAGE_BYTES = 8_000_000. (The spec proposed 10 and 10 MB; those were invented numbers and are superseded by the values already in the codebase. One set of caps, not two.)
  • SQL uses bound parameters. Never format a caller-supplied value into a query string.
  • Commit style: Conventional Commits, subject ending (#NNN) with the sub-issue number, no hard wrapping in bodies.

Deviations from the spec, and why

Spec says This plan does Why
Caps of 10 photos / 10 MB Reuses existing 6 / 8 MB constants The codebase already defines these for the admin upload path. Two different caps for the same pipeline is a bug waiting to happen
"Three migrations" One migration file All three DDL changes ship together and a partial application is meaningless. node-pg-migrate runs a file in one transaction
Intake feature-flagged off No flag in this slice Nothing is reachable until the admin creates a link, and an absent or revoked token 404s. The flag earns its keep in slice 2, where a paid API call appears

File Structure

Created:

  • backend/migrations/1787500000000_add-intake-pipeline.jsupload_links, item_drafts, and the items.price_cents default
  • backend/src/imageUpload.ts — the shared multer pipeline, extracted from routes/admin.ts
  • backend/src/uploadLinks.ts — token generation and hashing, pure
  • backend/src/routes/adminUploadLinks.ts — admin CRUD and revoke
  • backend/src/routes/intake.ts — the public submission endpoint
  • backend/tests/unit/uploadLinks.test.ts
  • backend/tests/integration/uploadLinks.integration.test.ts
  • backend/tests/integration/intake.integration.test.ts
  • frontend/src/intake/Submit.tsx — the public submission page
  • frontend/src/intake/intakeApi.ts
  • frontend/src/admin/UploadLinks.tsx — the admin link-management screen

Modified:

  • backend/src/routes/admin.ts — the upload pipeline moves out; imports it back in
  • backend/src/rateLimit.ts — a limiter keyed on caller alone
  • backend/src/app.ts — mounts the two new routers
  • backend/tests/integration/setup/testDb.ts — new tables in the truncate list
  • frontend/src/main.tsx — the /submit/:token route
  • frontend/src/admin/Admin.tsx — a tab for upload links

Task 1: Schema

Files:

  • Create: backend/migrations/1787500000000_add-intake-pipeline.js
  • Modify: backend/tests/integration/setup/testDb.ts:31-39

Interfaces:

  • Consumes: nothing

  • Produces: tables upload_links and item_drafts; items.price_cents defaults to 8000

  • Step 1: Write the migration

Create backend/migrations/1787500000000_add-intake-pipeline.js:

exports.up = (pgm) => {
  pgm.sql(`
    CREATE TABLE IF NOT EXISTS upload_links (
      id SERIAL PRIMARY KEY,
      label TEXT NOT NULL,
      -- The token itself is never stored, only its digest. A leaked database
      -- is then not also a leaked set of working upload links, and the admin
      -- screen can show a token exactly once — at creation — for the same
      -- reason a password reset link is not re-readable.
      token_hash TEXT NOT NULL UNIQUE,
      revoked_at TIMESTAMPTZ,
      submission_count INTEGER NOT NULL DEFAULT 0,
      -- Null means no cap. A link handed to a regular contributor is
      -- open-ended; one handed out for a single box of stock is not.
      max_submissions INTEGER,
      last_used_at TIMESTAMPTZ,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );

    CREATE TABLE IF NOT EXISTS item_drafts (
      id SERIAL PRIMARY KEY,
      item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
      -- SET NULL rather than CASCADE: deleting a link must not delete the
      -- items that arrived through it. Provenance is lost; the goods are not.
      upload_link_id INTEGER REFERENCES upload_links(id) ON DELETE SET NULL,
      submitter_note TEXT,
      state TEXT NOT NULL DEFAULT 'queued',
      attempts INTEGER NOT NULL DEFAULT 0,
      model TEXT,
      ai_name TEXT,
      ai_description TEXT,
      ai_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
      ai_tag_names TEXT[],
      ai_suggested_price_cents INTEGER,
      -- ai | default | admin. Where the item's current price came from.
      -- Recorded rather than inferred: a model that happens to suggest exactly
      -- 8000, or an admin who deliberately types the model's number, both
      -- collapse any comparison-based guess.
      price_source TEXT NOT NULL DEFAULT 'default',
      ai_error TEXT,
      input_tokens INTEGER,
      output_tokens INTEGER,
      cost_micros INTEGER,
      drafted_at TIMESTAMPTZ,
      reviewed_at TIMESTAMPTZ,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );

    -- The queue reads by state, and everything else reads by item.
    CREATE INDEX IF NOT EXISTS item_drafts_state_idx ON item_drafts (state);

    -- A submitted item is priced on arrival rather than left unpriced, so the
    -- column keeps NOT NULL and only gains a fallback. The AI columns above
    -- stay null until the drafting worker exists.
    ALTER TABLE items ALTER COLUMN price_cents SET DEFAULT 8000;
  `);
};

exports.down = (pgm) => {
  pgm.sql(`
    ALTER TABLE items ALTER COLUMN price_cents DROP DEFAULT;
    DROP TABLE IF EXISTS item_drafts;
    DROP TABLE IF EXISTS upload_links;
  `);
};
  • Step 2: Add the new tables to the integration truncate list

In backend/tests/integration/setup/testDb.ts, the TRUNCATE in resetDb() becomes — note item_drafts and upload_links lead, since item_drafts references items:

  await testPool.query(`
    TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts, shipping_addresses,
      cart_items, carts, customer_tokens, customer_sessions, favorites, customers, item_tags,
      item_images, items, tags, categories
    RESTART IDENTITY CASCADE
  `);
  • Step 3: Run the migration up and back down
cd backend
npm run migrate:up
npm run migrate:down
npm run migrate:up

Expected: three clean runs. The down must succeed — an irreversible migration is a migration that cannot be tested.

  • Step 4: Confirm the existing suite still passes
cd backend && npm run test:integration

Expected: PASS. The price default is additive, so nothing existing should shift.

  • Step 5: Commit
git add backend/migrations/1787500000000_add-intake-pipeline.js backend/tests/integration/setup/testDb.ts
git commit -m "feat(intake): add upload_links and item_drafts, and default an item's price (#NNN)"

Task 2: Extract the shared image-upload pipeline

This is a pure refactor. No behaviour changes; the existing tests are the safety net.

Files:

  • Create: backend/src/imageUpload.ts
  • Modify: backend/src/routes/admin.ts:1-300

Interfaces:

  • Consumes: uploadTypes.ts (ALLOWED_IMAGE_TYPES, SIGNATURE_BYTES, extensionFor, isAllowedImageType, signatureMatches)

  • Produces:

    • uploadImages: (req, res, next) => void — multer middleware, field name images
    • verifyUploadedImages(req: Request): Promise<string | null> — refusal message, or null
    • insertItemImages(client: PoolClient, itemId: number, files: Express.Multer.File[], firstSortOrder: number): Promise<void>
    • MAX_IMAGES_PER_REQUEST: number, MAX_IMAGE_BYTES: number
  • Step 1: Run the upload tests first, to know they pass before you touch anything

cd backend && npx jest -c jest.integration.config.js --runInBand uploadValidation adminInventory

Expected: PASS. Record this — it is the comparison for Step 4.

  • Step 2: Move the code

Create backend/src/imageUpload.ts and move into it, unchanged, from routes/admin.ts: UnsupportedImageTypeError, UPLOADS_DIR, MAX_IMAGES_PER_REQUEST, MAX_IMAGE_BYTES, MAX_TEXT_FIELDS, MAX_TEXT_FIELD_BYTES, storage, upload, readHead, discardUploads, discardUnlessAccepted, verifyUploadedImages, uploadImages, and insertItemImages.

Keep every existing comment verbatim. They record why the code is shaped as it is (#95, #103, #180) and are the most valuable thing being moved.

Export uploadImages, verifyUploadedImages, insertItemImages, MAX_IMAGES_PER_REQUEST, MAX_IMAGE_BYTES. Everything else stays module-private.

Add this at the top of the new file:

/**
 * The one validated path from a multipart request to files on the uploads
 * volume.
 *
 * Extracted from routes/admin.ts when a second caller appeared (#220's public
 * intake endpoint). It is deliberately one module rather than two similar ones:
 * every property that makes uploads safe here — the type allowlist, the
 * magic-byte check after the write, names from a CSPRNG rather than from
 * `originalname`, and the cleanup of anything a failed request left behind —
 * is a property a second implementation would have to reproduce exactly. A
 * near-copy that drifted would be precisely the gap #95 and #103 exist to
 * close.
 */
  • Step 3: Import them back into routes/admin.ts
import {
  uploadImages,
  verifyUploadedImages,
  insertItemImages,
  MAX_IMAGES_PER_REQUEST
} from '../imageUpload';

Remove the now-unused imports from ../uploadTypes, multer, fs, and randomUUID from routes/admin.ts — but only those genuinely no longer referenced. Let npm run lint tell you which.

  • Step 4: Run the same tests and the whole suite
cd backend
npm run lint
npm run build
npx jest -c jest.integration.config.js --runInBand uploadValidation adminInventory
npm run test:unit

Expected: identical results to Step 1, plus a clean lint and build. routesAreWrapped must still pass.

  • Step 5: Commit
git add backend/src/imageUpload.ts backend/src/routes/admin.ts
git commit -m "refactor(uploads): extract the validated image pipeline for a second caller (#NNN)"

Task 3: Token generation and hashing

Files:

  • Create: backend/src/uploadLinks.ts
  • Test: backend/tests/unit/uploadLinks.test.ts

Interfaces:

  • Consumes: nothing

  • Produces:

    • generateToken(): string — 43-character base64url, 256 bits of CSPRNG entropy
    • hashToken(token: string): string — 64-character lowercase hex SHA-256
  • Step 1: Write the failing test

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

import { generateToken, hashToken } from '../../src/uploadLinks';

describe('generateToken', () => {
  it('produces a URL-safe token with no padding', () => {
    expect(generateToken()).toMatch(/^[A-Za-z0-9_-]{43}$/);
  });

  // The token is the entire access control on the intake endpoint. If two
  // calls could collide, one person's link would open another's.
  it('does not repeat', () => {
    const seen = new Set(Array.from({ length: 1000 }, () => generateToken()));
    expect(seen.size).toBe(1000);
  });
});

describe('hashToken', () => {
  it('is a lowercase hex sha256 digest', () => {
    expect(hashToken('abc')).toBe(
      'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
    );
  });

  it('is stable across calls, so a stored digest keeps matching', () => {
    const token = generateToken();
    expect(hashToken(token)).toBe(hashToken(token));
  });

  it('gives different tokens different digests', () => {
    expect(hashToken(generateToken())).not.toBe(hashToken(generateToken()));
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js uploadLinks

Expected: FAIL — Cannot find module '../../src/uploadLinks'.

  • Step 3: Write the implementation

Create backend/src/uploadLinks.ts:

import crypto from 'crypto';

/**
 * Issuing and recognising the tokens that open the public intake endpoint.
 *
 * Kept apart from the routes so the rules are pure and testable directly, the
 * same reasoning as `uploadTypes.ts` and `keyByCallerAndEmail`.
 */

// 32 bytes — 256 bits. base64url so the value survives being pasted into a URL,
// a chat message and a QR code without escaping.
const TOKEN_BYTES = 32;

export function generateToken(): string {
  return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
}

/**
 * The digest stored against a link.
 *
 * SHA-256 rather than bcrypt, deliberately. A password hash is slow on purpose
 * because a human password has little entropy and must survive an offline
 * dictionary attack. This is 256 bits from a CSPRNG: there is no dictionary,
 * and guessing is not a threat that slowing the hash addresses. Meanwhile the
 * digest is computed on every submission request, so a deliberately slow hash
 * would be a denial-of-service surface on an unauthenticated endpoint.
 *
 * No timing-safe comparison is needed here because the lookup is an indexed
 * equality match on the digest, not a byte-by-byte compare of the secret — and
 * an attacker who could mount a timing attack against a 256-bit random value
 * would still need the value.
 */
export function hashToken(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}
  • Step 4: Run the test to verify it passes
cd backend && npx jest -c jest.unit.config.js uploadLinks

Expected: PASS, 5 tests.

  • Step 5: Commit
git add backend/src/uploadLinks.ts backend/tests/unit/uploadLinks.test.ts
git commit -m "feat(intake): generate and hash upload link tokens (#NNN)"

Files:

  • Create: backend/src/routes/adminUploadLinks.ts
  • Modify: backend/src/app.ts:74-80
  • Test: backend/tests/integration/uploadLinks.integration.test.ts

Interfaces:

  • Consumes: generateToken, hashToken from src/uploadLinks; pool, requireRow from src/db; asyncRoute

  • Produces:

    • GET /api/admin/upload-linksUploadLinkRow[], never including a token
    • POST /api/admin/upload-links {label, maxSubmissions?}201 with {...row, token, url} — the only time the token is ever returned
    • POST /api/admin/upload-links/:id/revoke200 with the updated row
  • Step 1: Write the failing test

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

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

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

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

describe('issuing an upload link', () => {
  it('returns the token exactly once, at creation', async () => {
    const created = await request(app)
      .post('/api/admin/upload-links')
      .send({ label: 'Sarah' });

    expect(created.status).toBe(201);
    expect(created.body.label).toBe('Sarah');
    expect(created.body.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
    expect(created.body.url).toContain(`/submit/${created.body.token}`);

    const listed = await request(app).get('/api/admin/upload-links');
    expect(listed.status).toBe(200);
    expect(listed.body).toHaveLength(1);
    // The whole point of storing a digest: the listing cannot leak it back.
    expect(listed.body[0].token).toBeUndefined();
    expect(listed.body[0].token_hash).toBeUndefined();
  });

  it('stores the digest rather than the token', async () => {
    const created = await request(app)
      .post('/api/admin/upload-links')
      .send({ label: 'Estate sale box 3' });

    const { rows } = await pool.query(`SELECT token_hash FROM upload_links`);
    expect(rows[0].token_hash).not.toBe(created.body.token);
    expect(rows[0].token_hash).toMatch(/^[a-f0-9]{64}$/);
  });

  it('refuses a link with no label', async () => {
    const res = await request(app).post('/api/admin/upload-links').send({ label: '  ' });
    expect(res.status).toBe(400);
  });

  it('refuses a non-positive submission cap', async () => {
    const res = await request(app)
      .post('/api/admin/upload-links')
      .send({ label: 'Bad cap', maxSubmissions: 0 });
    expect(res.status).toBe(400);
  });
});

describe('revoking an upload link', () => {
  it('stamps revoked_at and reports it in the listing', async () => {
    const created = await request(app)
      .post('/api/admin/upload-links')
      .send({ label: 'Temporary' });

    const revoked = await request(app)
      .post(`/api/admin/upload-links/${created.body.id}/revoke`);

    expect(revoked.status).toBe(200);
    expect(revoked.body.revoked_at).not.toBeNull();
  });

  it('is idempotent, so a second click is not an error', async () => {
    const created = await request(app)
      .post('/api/admin/upload-links')
      .send({ label: 'Temporary' });

    await request(app).post(`/api/admin/upload-links/${created.body.id}/revoke`);
    const second = await request(app).post(`/api/admin/upload-links/${created.body.id}/revoke`);

    expect(second.status).toBe(200);
  });

  it('404s for a link that does not exist', async () => {
    const res = await request(app).post('/api/admin/upload-links/9999/revoke');
    expect(res.status).toBe(404);
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.integration.config.js --runInBand uploadLinks.integration

Expected: FAIL — 404s, because the router is not mounted.

  • Step 3: Write the router

Create backend/src/routes/adminUploadLinks.ts:

import { Router, Request, Response } from 'express';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
import { generateToken, hashToken } from '../uploadLinks';

const router = Router();

/**
 * Issuing and retiring the links that open the public intake endpoint.
 *
 * A link is named because provenance matters more than convenience here: when
 * one leaks, the question is which one, and the answer has to come from
 * somewhere. The token is returned by exactly one response in this file and is
 * unrecoverable afterwards, which is why the admin screen has to present it as
 * a one-time reveal rather than a field to come back to.
 */

/** Shaped so a `SELECT *` can never leak the digest into a response. */
const LINK_SELECT = `
  SELECT id, label, revoked_at, submission_count, max_submissions, last_used_at, created_at
  FROM upload_links
`;

interface UploadLinkRow {
  id: number;
  label: string;
  revoked_at: string | null;
  submission_count: number;
  max_submissions: number | null;
  last_used_at: string | null;
  created_at: string;
}

router.get('/', asyncRoute(async (_req: Request, res: Response) => {
  const { rows } = await pool.query<UploadLinkRow>(`${LINK_SELECT} ORDER BY created_at DESC`);
  res.json(rows);
}));

router.post('/', asyncRoute(async (req: Request, res: Response) => {
  const label = typeof req.body?.label === 'string' ? req.body.label.trim() : '';
  if (label === '') {
    return res.status(400).json({ error: 'a label is required' });
  }

  // Absent means no cap, which is a different thing from a cap of zero — a
  // link that can never be used is a mistake rather than an intent.
  const rawCap = req.body?.maxSubmissions;
  let maxSubmissions: number | null = null;
  if (rawCap !== undefined && rawCap !== null && rawCap !== '') {
    const parsed = Number(rawCap);
    if (!Number.isInteger(parsed) || parsed < 1) {
      return res.status(400).json({ error: 'maxSubmissions must be a positive whole number' });
    }
    maxSubmissions = parsed;
  }

  const token = generateToken();
  const { rows } = await pool.query<UploadLinkRow>(
    `INSERT INTO upload_links (label, token_hash, max_submissions)
     VALUES ($1, $2, $3)
     RETURNING id, label, revoked_at, submission_count, max_submissions, last_used_at, created_at`,
    [label, hashToken(token), maxSubmissions]
  );
  const link = requireRow(rows, 'the upload_links INSERT');

  // PUBLIC_URL is already required alongside SMTP and is what every other
  // outbound link is built from. Empty in a local environment, which yields a
  // relative URL the admin screen can still render usefully.
  const base = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
  res.status(201).json({ ...link, token, url: `${base}/submit/${token}` });
}));

router.post('/:id/revoke', asyncRoute(async (req: Request, res: Response) => {
  // COALESCE so revoking twice keeps the original timestamp: the useful fact is
  // when access ended, and a second click should not rewrite that.
  const { rows } = await pool.query<UploadLinkRow>(
    `UPDATE upload_links SET revoked_at = COALESCE(revoked_at, now())
     WHERE id = $1
     RETURNING id, label, revoked_at, submission_count, max_submissions, last_used_at, created_at`,
    [req.params.id]
  );

  if (rows.length === 0) {
    return res.status(404).json({ error: 'not found' });
  }
  res.json(rows[0]);
}));

export default router;
  • Step 4: Mount it

In backend/src/app.ts, beside the other admin routers and above the catch-all app.use('/api/admin', ...):

import adminUploadLinksRouter from './routes/adminUploadLinks';
app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter);

Order matters: /api/admin is mounted last and would otherwise swallow the path. requireAdminGate goes on the router itself, per the reasoning in middleware/adminGate.ts.

  • Step 5: Run the tests
cd backend
npx jest -c jest.integration.config.js --runInBand uploadLinks.integration
npm run test:unit
npm run lint && npm run build

Expected: PASS, 7 integration tests. routesAreWrapped must pass — every handler above is wrapped.

  • Step 6: Commit
git add backend/src/routes/adminUploadLinks.ts backend/src/app.ts backend/tests/integration/uploadLinks.integration.test.ts
git commit -m "feat(intake): issue and revoke named upload links (#NNN)"

Task 5: The public submission endpoint

Files:

  • Create: backend/src/routes/intake.ts
  • Modify: backend/src/rateLimit.ts (append), backend/src/app.ts
  • Test: backend/tests/integration/intake.integration.test.ts

Interfaces:

  • Consumes: uploadImages, verifyUploadedImages, insertItemImages from src/imageUpload; hashToken from src/uploadLinks; intakeLimiter from src/rateLimit

  • Produces:

    • GET /api/intake/:token{label} for a usable link, else 404
    • POST /api/intake/:token (multipart: images[], note) → 201 {ok: true}, else 404 / 400
    • keyByCaller(req: Request): string exported from rateLimit.ts
  • Step 1: Write the failing test

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

import request from 'supertest';
import { promises as fs } from 'fs';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';

const UPLOADS_DIR = process.env.UPLOADS_DIR as string;

// The same 1x1 PNG the upload validation suite uses, so the accepted case
// exercises the whole path rather than a buffer that merely starts right.
const PNG = Buffer.from(
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
  'base64'
);

// multer.diskStorage does not create its destination.
beforeAll(async () => {
  await fs.mkdir(UPLOADS_DIR, { recursive: true });
});

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

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

async function storedFiles(): Promise<string[]> {
  return fs.readdir(UPLOADS_DIR);
}

async function issueLink(label = 'Sarah', maxSubmissions?: number): Promise<string> {
  const res = await request(app)
    .post('/api/admin/upload-links')
    .send({ label, ...(maxSubmissions === undefined ? {} : { maxSubmissions }) });
  expect(res.status).toBe(201);
  return res.body.token;
}

describe('checking a link before showing the form', () => {
  it('names the link so the page can greet the sender', async () => {
    const token = await issueLink('Sarah');
    const res = await request(app).get(`/api/intake/${token}`);

    expect(res.status).toBe(200);
    expect(res.body.label).toBe('Sarah');
  });

  // 404 rather than 403 throughout: whether a link exists is not something a
  // stranger needs to be able to distinguish. Same reasoning as uploads.ts.
  it('404s an unknown token', async () => {
    const res = await request(app).get('/api/intake/not-a-real-token');
    expect(res.status).toBe(404);
  });

  it('404s a revoked link', async () => {
    const token = await issueLink();
    const { rows } = await pool.query(`SELECT id FROM upload_links`);
    await request(app).post(`/api/admin/upload-links/${rows[0].id}/revoke`);

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

describe('submitting an item', () => {
  it('creates a pending item with its images, note and provenance', async () => {
    const token = await issueLink('Sarah');

    const res = await request(app)
      .post(`/api/intake/${token}`)
      .field('note', 'Hand-thrown stoneware, chip on the base')
      .attach('images', PNG, 'front.png')
      .attach('images', PNG, 'back.png');

    expect(res.status).toBe(201);
    expect(res.body.ok).toBe(true);

    const { rows: items } = await pool.query(
      `SELECT id, status, price_cents FROM items`
    );
    expect(items).toHaveLength(1);
    expect(items[0].status).toBe('pending');
    // The default from Task 1, not a price anyone chose.
    expect(items[0].price_cents).toBe(8000);

    const { rows: images } = await pool.query(
      `SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
      [items[0].id]
    );
    expect(images).toHaveLength(2);
    expect(images[0].image_path).toMatch(/^\/uploads\/[a-f0-9-]+\.png$/);

    const { rows: drafts } = await pool.query(
      `SELECT submitter_note, state, price_source, upload_link_id FROM item_drafts WHERE item_id = $1`,
      [items[0].id]
    );
    expect(drafts[0].submitter_note).toBe('Hand-thrown stoneware, chip on the base');
    expect(drafts[0].state).toBe('queued');
    expect(drafts[0].price_source).toBe('default');
    expect(drafts[0].upload_link_id).not.toBeNull();
  });

  it('counts the submission against the link', async () => {
    const token = await issueLink();
    await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');

    const { rows } = await pool.query(`SELECT submission_count, last_used_at FROM upload_links`);
    expect(rows[0].submission_count).toBe(1);
    expect(rows[0].last_used_at).not.toBeNull();
  });

  it('refuses a submission with no photos', async () => {
    const token = await issueLink();
    const res = await request(app).post(`/api/intake/${token}`).field('note', 'nothing attached');

    expect(res.status).toBe(400);
    const { rows } = await pool.query(`SELECT id FROM items`);
    expect(rows).toHaveLength(0);
  });

  // The file is named .png and declared image/png, but the bytes are not.
  // This is the check that cannot happen before the write.
  it('refuses a file whose bytes disagree with its type', async () => {
    const token = await issueLink();
    const res = await request(app)
      .post(`/api/intake/${token}`)
      .attach('images', Buffer.from('<html>not an image</html>'), {
        filename: 'evil.png',
        contentType: 'image/png'
      });

    expect(res.status).toBe(400);
    const { rows } = await pool.query(`SELECT id FROM items`);
    expect(rows).toHaveLength(0);
  });

  it('404s a revoked link without creating anything', async () => {
    const token = await issueLink();
    const { rows: links } = await pool.query(`SELECT id FROM upload_links`);
    await request(app).post(`/api/admin/upload-links/${links[0].id}/revoke`);

    const res = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');

    expect(res.status).toBe(404);
    const { rows } = await pool.query(`SELECT id FROM items`);
    expect(rows).toHaveLength(0);
  });

  // The reason `requireUsableLink` is ordered ahead of `uploadImages`. Without
  // that ordering this still returns 404 and still creates no item — the bytes
  // just reach the disk first and are deleted afterwards. This asserts they
  // never arrive, so a future reordering of the middleware fails here rather
  // than quietly handing an unauthenticated caller control of disk churn.
  it('writes nothing to the uploads volume for a token that does not work', async () => {
    const before = await storedFiles();

    const res = await request(app)
      .post('/api/intake/not-a-real-token')
      .attach('images', PNG, 'a.png');

    expect(res.status).toBe(404);
    expect(await storedFiles()).toEqual(before);
  });

  it('stops accepting once the link hits its cap', async () => {
    const token = await issueLink('One shot', 1);

    const first = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');
    expect(first.status).toBe(201);

    const second = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'b.png');
    expect(second.status).toBe(404);

    const { rows } = await pool.query(`SELECT id FROM items`);
    expect(rows).toHaveLength(1);
  });
});

describe('a submitted item does not reach the storefront', () => {
  it('is absent from the public catalogue', async () => {
    const token = await issueLink();
    await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png');

    const res = await request(app).get('/api/items');
    expect(res.status).toBe(200);
    expect(res.body).toHaveLength(0);
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.integration.config.js --runInBand intake.integration

Expected: FAIL — 404s on every intake path, since the router is not mounted.

  • Step 3: Add the rate limiter

Append to backend/src/rateLimit.ts:

/**
 * Keyed on the caller alone, because an intake submission carries no email.
 *
 * The comment on `keyByCallerAndEmail` warns that a bare `ip:` bucket is a
 * shared allowance rather than a per-caller one, and that is accepted here: the
 * link is the per-caller identity, and its `submission_count` and
 * `max_submissions` are the per-caller cap. This limiter exists for a different
 * job — bounding what a single address can throw at an unauthenticated endpoint
 * that writes files to disk.
 */
export function keyByCaller(req: Request): string {
  return ipKeyGenerator(req.ip ?? '');
}

// Deliberately looser than the password-reset allowance. Someone photographing
// a box of stock legitimately submits several items in a row, and the cost of
// refusing them is a lost consignment.
export const intakeLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20,
  keyGenerator: keyByCaller,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'too many submissions — please try again later' }
});
  • Step 4: Write the router

Create backend/src/routes/intake.ts:

import { Router, Request, Response, NextFunction } from 'express';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
import { hashToken } from '../uploadLinks';
import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload';
import { intakeLimiter } from '../rateLimit';

const router = Router();

/**
 * The public way in: photos of one item, from someone with no account.
 *
 * Everything here is reachable by a stranger holding a URL, so the shape of
 * every refusal matters. Refusals are 404 rather than 403 throughout —
 * unknown, revoked and exhausted links are indistinguishable from the outside,
 * because whether a link exists is not something a stranger needs to be able
 * to learn. That is the same reasoning `uploads.ts` applies to files.
 *
 * The AI is deliberately not called here. A slow or failing model request must
 * not turn into a failed upload for someone who did nothing wrong, and the
 * photos may be the only copy — the item is often no longer in the sender's
 * hands. The row is left at `state='queued'` for the worker (#220, slice 2).
 */

interface LinkRow {
  id: number;
  label: string;
}

/** The link resolved by `requireUsableLink`, carried to the handler. */
interface IntakeRequest extends Request {
  uploadLink?: LinkRow;
}

/**
 * The link a token opens, or null.
 *
 * The cap is applied in SQL rather than in a later branch so that "usable" is
 * one concept with one definition, used identically by the GET and the POST.
 */
async function usableLink(token: string): Promise<LinkRow | null> {
  const { rows } = await pool.query<LinkRow>(
    `SELECT id, label FROM upload_links
     WHERE token_hash = $1
       AND revoked_at IS NULL
       AND (max_submissions IS NULL OR submission_count < max_submissions)`,
    [hashToken(token)]
  );
  return rows[0] ?? null;
}

router.get('/:token', intakeLimiter, asyncRoute(async (req: Request, res: Response) => {
  const link = await usableLink(req.params.token);
  if (!link) {
    return res.status(404).json({ error: 'not found' });
  }
  // The label only. Nothing about the catalogue, the admin, or other links.
  res.json({ label: link.label });
}));

/**
 * Resolves the link *before* multer runs, so a stranger holding a bad token
 * cannot cause a single byte to be written to the uploads volume.
 *
 * `discardUnlessAccepted` would delete those files afterwards, but "written
 * then deleted" is a materially worse position than "never written" on an
 * endpoint the whole internet can reach: it is disk churn an unauthenticated
 * caller controls, and it leans on a cleanup that a crash between the write
 * and the unlink would skip. Ordering this middleware ahead of `uploadImages`
 * is the whole mitigation.
 */
const requireUsableLink = asyncRoute(async (req: Request, res: Response, next: NextFunction) => {
  const link = await usableLink(req.params.token);
  if (!link) {
    res.status(404).json({ error: 'not found' });
    return;
  }
  (req as IntakeRequest).uploadLink = link;
  next();
});

router.post('/:token', intakeLimiter, requireUsableLink, uploadImages, asyncRoute(async (req: Request, res: Response) => {
  // Set by requireUsableLink above. Re-checked rather than asserted non-null,
  // so a future reordering of the middleware fails as a 404 rather than as a
  // crash on undefined.
  const link = (req as IntakeRequest).uploadLink;
  if (!link) {
    return res.status(404).json({ error: 'not found' });
  }

  const files = (req.files as Express.Multer.File[]) || [];
  if (files.length === 0) {
    return res.status(400).json({ error: 'at least one photo is required' });
  }

  const refusal = await verifyUploadedImages(req);
  if (refusal) {
    return res.status(400).json({ error: refusal });
  }

  const note = typeof req.body?.note === 'string' ? req.body.note.trim() : '';

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // A placeholder name. `items.name` is NOT NULL and nobody has named this
    // yet — the drafting worker or the admin replaces it. A timestamp is used
    // rather than "Untitled" so several waiting submissions are still tellable
    // apart in the inventory list.
    const { rows } = await client.query<{ id: number }>(
      `INSERT INTO items (name, description, status)
       VALUES ($1, $2, 'pending')
       RETURNING id`,
      [`Submission ${new Date().toISOString()}`, null]
    );
    const itemId = requireRow(rows, 'the intake item INSERT').id;

    await insertItemImages(client, itemId, files, 0);

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

    // Counted inside the transaction and guarded on the same conditions as the
    // lookup, so two submissions racing on the last slot of a capped link
    // cannot both succeed.
    const counted = await client.query(
      `UPDATE upload_links
       SET submission_count = submission_count + 1, last_used_at = now()
       WHERE id = $1
         AND revoked_at IS NULL
         AND (max_submissions IS NULL OR submission_count < max_submissions)`,
      [link.id]
    );
    if (counted.rowCount === 0) {
      await client.query('ROLLBACK');
      return res.status(404).json({ error: 'not found' });
    }

    await client.query('COMMIT');
    // No item id in the response: the sender has no business knowing about
    // the catalogue, and nothing they can do with it.
    res.status(201).json({ ok: true });
  } catch (err) {
    await client.query('ROLLBACK');
    console.error(err);
    res.status(500).json({ error: 'internal error' });
  } finally {
    client.release();
  }
}));

export default router;
  • Step 5: Mount it

In backend/src/app.ts, with the other public routers — no requireAdminGate:

import intakeRouter from './routes/intake';
app.use('/api/intake', intakeRouter);
  • Step 6: Run the tests
cd backend
npx jest -c jest.integration.config.js --runInBand intake.integration
npm run test:unit
npm run lint && npm run build

Expected: PASS, 11 integration tests, and the full unit suite including routesAreWrapped.

  • Step 7: Run the whole backend suite before committing
cd backend && npm run test:integration

Expected: PASS. This is the point where a mistake in Task 2's extraction would surface in the admin inventory tests.

  • Step 8: Commit
git add backend/src/routes/intake.ts backend/src/rateLimit.ts backend/src/app.ts backend/tests/integration/intake.integration.test.ts
git commit -m "feat(intake): accept photo submissions through a shared link (#NNN)"

Task 6: The public submission page

Files:

  • Create: frontend/src/intake/intakeApi.ts, frontend/src/intake/Submit.tsx
  • Modify: frontend/src/main.tsx:101-110

Interfaces:

  • Consumes: GET/POST /api/intake/:token

  • Produces: route /submit/:token

  • Step 1: Write the API client

Create frontend/src/intake/intakeApi.ts:

export interface IntakeLink {
  label: string;
}

export async function fetchIntakeLink(token: string): Promise<IntakeLink | null> {
  const res = await fetch(`/api/intake/${encodeURIComponent(token)}`);
  // Every refusal is a 404 by design, so there is one "this link does not
  // work" state rather than several the page would have to explain.
  if (!res.ok) return null;
  return res.json();
}

export async function submitItem(
  token: string,
  files: File[],
  note: string
): Promise<{ ok: true } | { ok: false; error: string }> {
  const body = new FormData();
  for (const file of files) body.append('images', file);
  body.append('note', note);

  const res = await fetch(`/api/intake/${encodeURIComponent(token)}`, { method: 'POST', body });
  if (res.ok) return { ok: true };

  const payload = await res.json().catch(() => ({}));
  return { ok: false, error: payload.error ?? 'Something went wrong. Please try again.' };
}
  • Step 2: Write the page

Create frontend/src/intake/Submit.tsx:

import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import Typography from 'antd/es/typography';
import Card from 'antd/es/card';
import Upload from 'antd/es/upload';
import ButtonAntd from 'antd/es/button';
import Input from 'antd/es/input';
import Alert from 'antd/es/alert';
import Spin from 'antd/es/spin';
import Space from 'antd/es/space';
import type { UploadFile } from 'antd/es/upload/interface';
import { fetchIntakeLink, submitItem } from './intakeApi';

const { Title, Paragraph } = Typography;
const { TextArea } = Input;

// The three types the server will accept. Listed here so the file picker
// offers exactly those; the server checks the bytes regardless.
const ACCEPT = 'image/jpeg,image/png,image/webp';
const MAX_IMAGES = 6;

export default function Submit() {
  const { token = '' } = useParams();
  const [label, setLabel] = useState<string | null>(null);
  const [checking, setChecking] = useState(true);
  const [files, setFiles] = useState<UploadFile[]>([]);
  const [note, setNote] = useState('');
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    void fetchIntakeLink(token).then((link) => {
      if (cancelled) return;
      setLabel(link?.label ?? null);
      setChecking(false);
    });
    return () => {
      cancelled = true;
    };
  }, [token]);

  async function send() {
    setSending(true);
    setError(null);
    const result = await submitItem(
      token,
      files.map((f) => f.originFileObj as File).filter(Boolean),
      note
    );
    setSending(false);
    if (result.ok) {
      setSent(true);
      return;
    }
    setError(result.error);
  }

  if (checking) return <Spin />;

  // One state for every refusal, matching the server's single 404.
  if (label === null) {
    return (
      <Card style={{ maxWidth: 640, margin: '3rem auto' }}>
        <Title level={3}>This link is not active</Title>
        <Paragraph>
          It may have been turned off, or already used as many times as it was meant for. Ask
          whoever sent it to you for a new one.
        </Paragraph>
      </Card>
    );
  }

  if (sent) {
    return (
      <Card style={{ maxWidth: 640, margin: '3rem auto' }}>
        <Title level={3}>Thank you  it arrived</Title>
        <Paragraph>
          Somebody will look at your photos and write it up. Nothing is listed for sale until
          they have.
        </Paragraph>
        <ButtonAntd
          onClick={() => {
            setFiles([]);
            setNote('');
            setSent(false);
          }}
        >
          Send another item
        </ButtonAntd>
      </Card>
    );
  }

  return (
    <Card style={{ maxWidth: 640, margin: '3rem auto' }}>
      <Title level={3}>Send in an item</Title>
      <Paragraph>
        Photos of one item, and anything you know about it. Send each item separately.
      </Paragraph>

      <Space direction="vertical" size="middle" style={{ width: '100%' }}>
        <Upload
          accept={ACCEPT}
          multiple
          listType="picture"
          maxCount={MAX_IMAGES}
          fileList={files}
          // Returning false keeps antd from uploading each file on selection;
          // they are sent together by `send` instead.
          beforeUpload={() => false}
          onChange={({ fileList }) => setFiles(fileList)}
        >
          <ButtonAntd>Choose photos</ButtonAntd>
        </Upload>

        <TextArea
          rows={4}
          value={note}
          onChange={(e) => setNote(e.target.value)}
          placeholder="What is it, what is it made of, how big, what condition, where did it come from? Anything you know helps — a photo cannot show any of it."
        />

        {error && <Alert type="error" message={error} showIcon />}

        <ButtonAntd type="primary" onClick={send} loading={sending} disabled={files.length === 0}>
          Send
        </ButtonAntd>
      </Space>
    </Card>
  );
}
  • Step 3: Add the route

In frontend/src/main.tsx, import and add inside <Routes location={backdrop}>:

import Submit from './intake/Submit';
        <Route path="/submit/:token" element={<Submit />} />
  • Step 4: Verify it builds and lints
cd frontend && npm run lint && npm run build

Expected: PASS. The build runs tsc first, so a wrong antd import path fails here.

  • Step 5: Try it against a running stack
.\scripts\start-local.ps1

Issue a link, then open http://localhost:5173/submit/<token>, attach two photos, add a note, send. Confirm in /admin that a pending item appeared with both images. Then check http://localhost:5173/submit/bogus shows the inactive-link card.

  • Step 6: Commit
git add frontend/src/intake frontend/src/main.tsx
git commit -m "feat(intake): add the public submission page (#NNN)"

Files:

  • Create: frontend/src/admin/UploadLinks.tsx
  • Modify: frontend/src/admin/Admin.tsx

Interfaces:

  • Consumes: GET/POST /api/admin/upload-links, POST /api/admin/upload-links/:id/revoke

  • Produces: an "Upload links" tab

  • Step 1: Write the screen

Create frontend/src/admin/UploadLinks.tsx:

import React, { useEffect, useState } from 'react';
import Table from 'antd/es/table';
import ButtonAntd from 'antd/es/button';
import Input from 'antd/es/input';
import Space from 'antd/es/space';
import Alert from 'antd/es/alert';
import Typography from 'antd/es/typography';
import Popconfirm from 'antd/es/popconfirm';
import Tag from 'antd/es/tag';

const { Paragraph, Text } = Typography;

interface UploadLink {
  id: number;
  label: string;
  revoked_at: string | null;
  submission_count: number;
  max_submissions: number | null;
  last_used_at: string | null;
  created_at: string;
}

export default function UploadLinks() {
  const [links, setLinks] = useState<UploadLink[]>([]);
  const [label, setLabel] = useState('');
  const [cap, setCap] = useState('');
  // Held only in component state and shown once. The server cannot return it
  // again — it stores a digest — so a refresh loses it deliberately.
  const [issued, setIssued] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function load() {
    const res = await fetch('/api/admin/upload-links');
    if (res.ok) setLinks(await res.json());
  }

  useEffect(() => {
    void load();
  }, []);

  async function create() {
    setError(null);
    const res = await fetch('/api/admin/upload-links', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ label, maxSubmissions: cap === '' ? undefined : Number(cap) })
    });
    if (!res.ok) {
      const payload = await res.json().catch(() => ({}));
      setError(payload.error ?? 'Could not create the link.');
      return;
    }
    const created = await res.json();
    setIssued(created.url);
    setLabel('');
    setCap('');
    await load();
  }

  async function revoke(id: number) {
    await fetch(`/api/admin/upload-links/${id}/revoke`, { method: 'POST' });
    await load();
  }

  return (
    <Space direction="vertical" size="large" style={{ width: '100%' }}>
      <Paragraph>
        Give one link per person or purpose. If a link is shared further than you meant, revoke
        that one  everything sent through it is kept.
      </Paragraph>

      <Space wrap>
        <Input
          placeholder="Who or what is this for?"
          value={label}
          onChange={(e) => setLabel(e.target.value)}
          style={{ width: 260 }}
        />
        <Input
          placeholder="Max uses (optional)"
          value={cap}
          onChange={(e) => setCap(e.target.value)}
          style={{ width: 160 }}
        />
        <ButtonAntd type="primary" onClick={create} disabled={label.trim() === ''}>
          Create link
        </ButtonAntd>
      </Space>

      {error && <Alert type="error" message={error} showIcon />}

      {issued && (
        <Alert
          type="success"
          showIcon
          message="Copy this link now"
          description={
            <>
              <Paragraph copyable={{ text: issued }}>
                <Text code>{issued}</Text>
              </Paragraph>
              <Text type="secondary">
                It is not stored and cannot be shown again. If you lose it, revoke this link and
                make another.
              </Text>
            </>
          }
          closable
          onClose={() => setIssued(null)}
        />
      )}

      <Table<UploadLink>
        rowKey="id"
        dataSource={links}
        pagination={false}
        columns={[
          { title: 'Label', dataIndex: 'label' },
          {
            title: 'Used',
            render: (_, row) =>
              row.max_submissions === null
                ? row.submission_count
                : `${row.submission_count} of ${row.max_submissions}`
          },
          {
            title: 'Status',
            render: (_, row) =>
              row.revoked_at ? <Tag>Revoked</Tag> : <Tag color="green">Active</Tag>
          },
          {
            title: '',
            render: (_, row) =>
              row.revoked_at ? null : (
                <Popconfirm
                  title="Revoke this link?"
                  description="Anyone holding it stops being able to send anything. Items already sent are kept."
                  onConfirm={() => revoke(row.id)}
                >
                  <ButtonAntd danger size="small">
                    Revoke
                  </ButtonAntd>
                </Popconfirm>
              )
          }
        ]}
      />
    </Space>
  );
}
  • Step 2: Add the tab

In frontend/src/admin/Admin.tsx, add the import alongside the other tab components:

import UploadLinks from './UploadLinks';

Then add the entry to the items array at line 388, after tags and before customers — it belongs with the catalogue-side tabs rather than the customer-side ones:

            { key: 'tags', label: 'Tags', children: <Tags /> },
            { key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
            { key: 'customers', label: 'Customers', children: <Customers /> },
  • Step 3: Verify it builds and lints
cd frontend && npm run lint && npm run build

Expected: PASS.

  • Step 4: Try it against a running stack

In /admin, create a link, confirm the one-time reveal appears and copies, reload the page and confirm the token is gone from the table, submit through the link, confirm the used count increments, then revoke it and confirm the submission page shows the inactive card.

  • Step 5: Commit
git add frontend/src/admin/UploadLinks.tsx frontend/src/admin/Admin.tsx
git commit -m "feat(intake): manage upload links from the admin (#NNN)"

Done when

  • A link created in /admin accepts a submission at /submit/<token>, and the item appears in the admin inventory as pending with its photos and note.
  • Revoking that link makes the page show the inactive card and the endpoint return 404.
  • A capped link stops at its cap.
  • A file whose bytes disagree with its declared type is refused and leaves nothing on disk or in the database.
  • npm run test:unit, npm run test:integration, npm run lint and npm run build all pass in backend/; npm run lint and npm run build pass in frontend/.

Not in this slice

The drafting worker, the notification email and its signed action links, and the review queue. item_drafts is created here with its AI columns unpopulated so slice 2 adds behaviour rather than schema. The price_source column is written as 'default' and not yet read.