Estimate: 16m · Depends on: 2.3.5, 2.3.9
Plugs a real file-upload pipeline into the description editor so paste/drop actually
persists. GitHub-comment model (finding #52, decision-authority rung 1 —
Motir is a dev-oriented Markdown tool): paste/drop any allowed file;
an image embeds inline (, it renders in the
Markdown body), any other file inserts as a link
([filename](url)) — because Markdown only renders images inline, a non-image
can only be a link, never a broken embed. This is NOT a general "attachments panel" (a
managed, work_item-linked list with download/delete) — that stays a first-class
Epic 5 feature and REUSES this same upload service/route/table. v1
storage = Vercel Blob via the @vercel/blob SDK and the
standard handleUpload client-direct-upload pattern (the file goes browser →
Blob directly; the server-side endpoint signs the upload URL and records the metadata).
Prerequisite (manual/human): Subtask 2.3.9 — the
Vercel Blob store + BLOB_READ_WRITE_TOKEN must be provisioned for live uploads
(Yue, dashboard). 2.3.7's CODE ships first behind a placeholder token (build green, the
Blob SDK mocked in tests); the feature goes live once 2.3.9 is done. This prerequisite is
now an explicit planned subtask per notes.html mistake #30 (plan ALL subtask types, not
just code).
Why Vercel Blob, not S3. Decision-authority ladder rung 1 (the
mirror product context): we're already on Vercel + Neon (MOTIR.md lines 549–574); adding
an S3 bucket means a new IAM principal, a new env-var matrix, and a separate per-PR
preview-cleanup story. Blob is in the same Vercel-managed lifecycle as the rest of the
stack — provisioned via the Marketplace, env vars auto-wired, per-preview-deploy cleanup
handled by the same lifecycle hook 1.1.10 set up. Justified deviation:
if/when an enterprise customer needs S3 for compliance, the storage adapter behind
lib/blob/uploader.ts is the single swap point.
Endpoint. POST /api/upload/issue-attachment using the
@vercel/blob/client handleUpload helper. Returns the public Blob
URL + the resolved MIME on success (the client needs the MIME to choose
![] vs []). Gate: session-required; workspaceId
resolved from the session (NOT from the client payload); rate-limited per user (~10
uploads / minute v1, a simple in-memory counter is fine pre-Epic-8); max file size 10 MB.
Allowed MIME = a general allowlist (NOT image-only): images
(image/png|jpeg|gif|webp|svg+xml), application/pdf, plain text /
logs (text/plain, text/csv, text/markdown),
application/zip, and common office docs — the allowlist lives in
lib/blob/allowlist.ts as a single exported set so Epic 5's attachments panel
reuses the exact policy. Reject everything else (executables/unknown). Rejections return
typed errors: FileTooLargeError 413, UnsupportedFileTypeError
415, RateLimitError 429.
Service + repository. Per CLAUDE.md 4-layer: the route calls
attachmentsService.uploadAttachment(file, ctx) — a GENERAL method, not
image-specific (the rename from the original card's uploadIssueImage is the
point of finding #52: one upload primitive serves the inline-image case AND Epic 5's
attachments). The service handles the gates and calls the Blob SDK + writes an
attachment row recording the uploader, workspaceId, blob URL, MIME, size, and
original filename. Schema addition: new attachment table —
id / workspaceId / uploaderUserId / blobUrl / mimeType / sizeBytes / originalFilename / createdAt, with RLS scoped to workspace (mirrors the
workflow_status pattern from 2.2.1 — pure workspace gate, no system-admin
hatch, no nullable workspaceId, see finding #44). NOT linked to a specific work_item v1
(the markdown image just references the URL — the row is a billing/audit trail). Linking
attachments to work_items is Epic 5 (file attachments as a first-class issue feature).
Editor wiring. 2.3.5's MarkdownEditor upload hook
generalizes from onImageUpload → onFileUpload (and the drop/paste
handler stops filtering to images so any allowed file is accepted); the wrapper gains a
default implementation when consumers don't pass one — the edit form (2.3.6) and the
create modal (2.3.3) both pick it up. The handler uploads, then splices by MIME:
image →  (inline), other →
[filename](url) (link), at the cursor position. Upload-in-flight
surfaces a progress placeholder ( /
[Uploading…](pending)) that's replaced on resolve; failure surfaces the typed
error message via toast AND leaves the placeholder reverted. Knock-on:
rename touches 2.3.5's shipped onImageUpload prop — a small refactor (2.3.3 /
2.3.6 currently rely on the default, don't pass it), update 2.3.5's component test.
add_attachment_and_rls creates the attachment table with the documented columns + forced RLS (pure workspace gate per finding #44).@vercel/blob added to package.json; BLOB_READ_WRITE_TOKEN documented in .env.example + the CI workflow (placeholder value sufficient for build).POST /api/upload/issue-attachment: thin route, session-required, calls attachmentsService.uploadAttachment; returns blob URL + MIME; typed errors → 413/415/429 mapping.attachmentsService.uploadAttachment (GENERAL, not image-only — finding #52) + attachmentRepository follow the 4-layer; repo write requires tx; service owns the rate-limit + size + MIME gates; the MIME allowlist is the single shared lib/blob/allowlist.ts set (reused by Epic 5).application/pdf SUCCEEDS, and an executable FAILS), rate-limit fires after threshold; cross-workspace uploader is impossible (workspaceId comes from session). (inline); drop a non-image (e.g. a .pdf) → resolves to [filename](…) (link); each shows the progress placeholder; the failure path reverts + surfaces the typed message.MarkdownView (2.3.5's render path): the image renders as an <img>, the file link as an <a>.MarkdownEditor prop rename onImageUpload → onFileUpload applied; its component test updated; 2.3.3 / 2.3.6 unaffected (they use the default).cleanup-preview-deployments.yml from 1.1.10) is updated to also delete the preview's Blob store; documented in the PR body if Vercel's auto-lifecycle doesn't cover it (verify against the Vercel Blob docs at execution time).components/ui/MarkdownEditor.tsx (2.3.5) — the upload hook to fulfill + generalize (onImageUpload → onFileUpload); drop/paste handler currently image-filteredprisma/migrations/.../workflow_status (2.2.1) — the migration shape to mirror for the new table@vercel/blob client-direct-upload docs — verify the exact handleUpload shape at execution time (the API is stable but evolves)cleanup-preview-deployments.yml — the per-PR cleanup workflow to extendmotir-core/CLAUDE.md — 4-layer + entity-naming + tx-required-on-writes