feat(intake): issue named upload links and accept photo submissions #222

Closed
opened 2026-08-29 08:09:57 -05:00 by bermudalamb · 1 comment
Owner

Slice 1 of #220, and the only one that is useful on its own with no AI and no email in it: hand someone a link, and photos of an item arrive in the inventory as pending.

Design: docs/superpowers/specs/2026-08-29-intake-pipeline-design.md
Plan: docs/superpowers/plans/2026-08-29-intake-upload-links.md — seven tasks, each with its own test cycle

Scope

An upload_links table holding hashed tokens the admin issues and revokes, named so a leak is answerable ("which link?") and containable (revoke that one). A public, rate-limited endpoint at /api/intake/:token accepting one item's photos plus a free-text note, and a page at /submit/:token to send them from. An item_drafts row carrying the note and the provenance, and an items row at status='pending' — already invisible to every public query, so nothing is live by accident.

The validated image pipeline in routes/admin.ts is extracted to a shared module rather than copied. Every property that makes uploads safe — the type allowlist, the magic-byte check after the write, names from a CSPRNG rather than from originalname, cleanup of what a failed request left behind — is a property a second implementation would have to reproduce exactly, and a near-copy that drifted is the gap #95 and #103 exist to close.

Three decisions the plan takes against the design

  • Image caps are the existing MAX_IMAGES_PER_REQUEST = 6 and MAX_IMAGE_BYTES = 8_000_000, not the 10 and 10 MB the design proposed. Those were invented numbers; the codebase already has caps for this exact pipeline, and two sets is a defect waiting to happen.
  • No feature flag in this slice. Nothing is reachable until a link is created, and an absent, revoked or exhausted token 404s. The flag earns its keep in slice 2, where a paid API call appears.
  • The link is resolved before multer runs. Otherwise a stranger with a bad token still causes bytes to be written and then deleted — disk churn an unauthenticated caller controls, leaning on an unlink a crash between write and delete would skip. The middleware ordering is asserted by a test so a later reorder fails loudly.

Refusals

Unknown, revoked and exhausted links are all 404, indistinguishable from outside. Whether a link exists is not something a stranger needs to learn — the same reasoning uploads.ts applies to files.

Done when

A link created in /admin accepts a submission that appears as a pending item with its photos and note; revoking it makes the page show an inactive card and the endpoint 404; a capped link stops at its cap; a file whose bytes disagree with its declared type is refused leaving nothing on disk or in the database; and the full backend suite, lint and build pass on both halves.

Part of #220

Slice 1 of #220, and the only one that is useful on its own with no AI and no email in it: hand someone a link, and photos of an item arrive in the inventory as pending. **Design:** `docs/superpowers/specs/2026-08-29-intake-pipeline-design.md` **Plan:** `docs/superpowers/plans/2026-08-29-intake-upload-links.md` — seven tasks, each with its own test cycle ## Scope An `upload_links` table holding hashed tokens the admin issues and revokes, named so a leak is answerable ("which link?") and containable (revoke that one). A public, rate-limited endpoint at `/api/intake/:token` accepting one item's photos plus a free-text note, and a page at `/submit/:token` to send them from. An `item_drafts` row carrying the note and the provenance, and an `items` row at `status='pending'` — already invisible to every public query, so nothing is live by accident. The validated image pipeline in `routes/admin.ts` is extracted to a shared module rather than copied. Every property that makes uploads safe — the type allowlist, the magic-byte check after the write, names from a CSPRNG rather than from `originalname`, cleanup of what a failed request left behind — is a property a second implementation would have to reproduce exactly, and a near-copy that drifted is the gap #95 and #103 exist to close. ## Three decisions the plan takes against the design - **Image caps are the existing `MAX_IMAGES_PER_REQUEST = 6` and `MAX_IMAGE_BYTES = 8_000_000`,** not the 10 and 10 MB the design proposed. Those were invented numbers; the codebase already has caps for this exact pipeline, and two sets is a defect waiting to happen. - **No feature flag in this slice.** Nothing is reachable until a link is created, and an absent, revoked or exhausted token 404s. The flag earns its keep in slice 2, where a paid API call appears. - **The link is resolved before multer runs.** Otherwise a stranger with a bad token still causes bytes to be written and then deleted — disk churn an unauthenticated caller controls, leaning on an unlink a crash between write and delete would skip. The middleware ordering is asserted by a test so a later reorder fails loudly. ## Refusals Unknown, revoked and exhausted links are all `404`, indistinguishable from outside. Whether a link exists is not something a stranger needs to learn — the same reasoning `uploads.ts` applies to files. ## Done when A link created in `/admin` accepts a submission that appears as a pending item with its photos and note; revoking it makes the page show an inactive card and the endpoint 404; a capped link stops at its cap; a file whose bytes disagree with its declared type is refused leaving nothing on disk or in the database; and the full backend suite, lint and build pass on both halves. Part of #220
Author
Owner

Two guards added to this issue

Reviewing the upload surface turned up more than the middleware-ordering fix already described. The ordering stops a caller with a bad token writing anything; a caller with a working one can still send 6 × 8 MB per request against a limiter allowing 20 requests per window, and nothing anywhere checks whether the volume can take it. The volume is shared with the admin upload path, so intake filling it takes the whole shop down rather than just intake.

Two small guards are now Tasks 8 and 9 in the plan:

Refuse uploads when the volume is nearly full. A free-space check on UPLOADS_DIR returning 503 below a one-gigabyte floor, applied to the admin item routes as well as intake — both write to the same volume, so guarding only the public half would be guarding the wrong thing. Fails closed: a volume that cannot be measured is not one to assume is empty. The threshold decision is a pure function with its own unit test.

Bound a link by default. As originally written the router sends null whenever maxSubmissions is absent, so the ordinary act of creating a link produced an unbounded one — a cap that has to be remembered is not a control. Absent now means the bounded default of 25, an explicit null means unlimited, and a number means itself. Unlimited becomes a visible decision rather than an omission, with a "No limit" checkbox on the admin form.

Two related things filed separately

  • #226 — re-encode uploads to strip EXIF and cut stored bytes. Touches the shared pipeline the admin path uses and adds a native dependency, so it earns its own review.
  • #227 — a global submission ceiling with an abuse alert. Needs its own state and an outbound email, which is more than a guard.

Worth knowing while this ships: until #226 lands, uploaded photos keep their EXIF — including GPS coordinates — and are served publicly. That is true of the admin upload path today and is not introduced here, but this issue widens who can put such a file there.

## Two guards added to this issue Reviewing the upload surface turned up more than the middleware-ordering fix already described. The ordering stops a caller with a *bad* token writing anything; a caller with a working one can still send 6 × 8 MB per request against a limiter allowing 20 requests per window, and nothing anywhere checks whether the volume can take it. The volume is shared with the admin upload path, so intake filling it takes the whole shop down rather than just intake. Two small guards are now Tasks 8 and 9 in the plan: **Refuse uploads when the volume is nearly full.** A free-space check on `UPLOADS_DIR` returning `503` below a one-gigabyte floor, applied to the admin item routes as well as intake — both write to the same volume, so guarding only the public half would be guarding the wrong thing. Fails closed: a volume that cannot be measured is not one to assume is empty. The threshold decision is a pure function with its own unit test. **Bound a link by default.** As originally written the router sends `null` whenever `maxSubmissions` is absent, so the ordinary act of creating a link produced an unbounded one — a cap that has to be remembered is not a control. Absent now means the bounded default of 25, an explicit `null` means unlimited, and a number means itself. Unlimited becomes a visible decision rather than an omission, with a "No limit" checkbox on the admin form. ## Two related things filed separately - #226 — re-encode uploads to strip EXIF and cut stored bytes. Touches the shared pipeline the admin path uses and adds a native dependency, so it earns its own review. - #227 — a global submission ceiling with an abuse alert. Needs its own state and an outbound email, which is more than a guard. Worth knowing while this ships: until #226 lands, uploaded photos keep their EXIF — including GPS coordinates — and are served publicly. That is true of the admin upload path today and is not introduced here, but this issue widens who can put such a file there.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#222