Developer API Image Upload 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: Add Twitter-style image upload to the public developer API — POST /api/v1/images returns an opaque media_id, and POST /api/v1/posts attaches those ids to a post, with a reconciling sweep that reclaims orphaned and deleted-post media.
Architecture: A new additive Media Prisma model tracks each upload (owner, storage key, resolved URL, status). The media_id is an API-surface abstraction only — posts still store resolved URLs in RMHark.imageUrls, so existing posts need zero migration. Core logic lives in small, dependency-injected lib/media/* modules (unit-testable without a DB); route handlers and a worker setInterval are thin glue. Cleanup is a reconciling sweep behind a purgeFromCdn storage abstraction so a future CDN is a one-file change.
Tech Stack: TanStack Start file routes, Prisma 7 (+ @prisma/adapter-pg), Zod, Vitest, nanoid, existing S3-or-local storage (lib/storage/s3.server.ts) and magic-byte validation (lib/slice-it/upload-validation.ts).
Global Constraints¶
Auth: every
/api/v1/*endpoint goes throughwithDeveloperApi(lib/api/with-developer-api.server.ts);ctx.userIdis the owner. Never trust client-supplied user ids.Limits (verbatim, mirror the in-app uploader): ≤ 4 images per post; ≤ 5 MB (
5 * 1024 * 1024) per image; formats png/jpg/webp/gif verified by magic bytes (validateImageBuffer+detectImageExt), never the declared content-type.Storage: reuse
feedImageKey/feedImageUrl/contentTypeForFilenamefromlib/storage/keys.ts; filename scheme<userId>-<ts>-<rand><ext>. Store image bytes as-is (no Sharp re-encode).Id format:
media_+nanoid(). Ids are opaque; never expose a raw storage URL in the upload response.Lifecycle: media is single-use. Orphan TTL = 24h (
24 * 60 * 60 * 1000). Deleted-post grace = 7 days (7 * 24 * 60 * 60 * 1000).Abuse controls (upload route only): tier-gated to starter+ via a dedicated
hasApiImageUpload(tier)capability (403feature_not_availableotherwise); rejectContent-Length > 5 MBwith 413 before reading the body; a dedicated 15 req/min per key limit (reusing the existingrateLimit/redisRateLimit, prefixdev-api-image); a tier-scaled daily quota (starter 200, pro 1 000, enterprise 5 000) → 429quota_exceeded. No token bucket / pending-cap / concurrency semaphore (dropped, YAGNI).Test location: unit tests go in
lib/__tests__/*.test.ts(picked up byvitest.config.ts). Run withpnpm exec vitest run <path>.Dependency injection:
lib/media/*server functions take theirprisma/storage collaborators as an argument so they unit-test with plain mocks (novi.mockof the prisma module). Route handlers pass the realprisma(@/lib/prisma.server) and storage functions.
File Structure¶
Create:
lib/media/id.ts—media_idformat helpers (pure).lib/media/policy.ts— upload/attach validation + limit constants (pure).lib/media/sweep-policy.ts— TTL constants +mediaExpiresAt(pure).lib/storage/cdn.server.ts—purgeFromCdn/cdnConfigured(CDN abstraction).lib/media/upload.server.ts—createMediaFromUpload(DI: prisma + putObject).lib/media/attach.server.ts—resolveMediaForPost+markMediaAttached(DI: prisma).lib/media/sweep.server.ts—sweepUnreferencedMedia(DI: prisma + storage + now).app/routes/api/v1/images.ts—POST /api/v1/imagesroute (thin).lib/media/quota.server.ts—checkDailyUploadQuota(DI: redis/in-process counter) + per-tier quota map.Tests:
lib/__tests__/media-id.test.ts,media-policy.test.ts,media-sweep-policy.test.ts,storage-cdn.test.ts,media-upload.test.ts,media-attach.test.ts,media-sweep.test.ts,media-quota.test.ts,entitlements-image-upload.test.ts.
Modify:
prisma/schema.prisma— addMediamodel +MediaStatusenum + back-relations onUserandRMHark.app/routes/api/v1/posts.ts— acceptmedia_idsin the POST handler.lib/entitlements.ts— addhasApiImageUpload(tier)capability.app/routes/api/v1/images.ts— wire abuse controls (capability gate, 413 guard, tight limit, daily quota) — built in Task 5, hardened in Task 9.server/recap/index.ts— add an hourlysetIntervalcalling the sweep..env.example— document CDN env vars.docs/developer-api.md— document the new endpoint +media_ids.
Task 1: Media Prisma model + migration¶
Files:
Modify:
prisma/schema.prisma(add model/enum near theRMHarkmodel ~line 1138; add back-relations onUser~line 12 andRMHark)
Interfaces:
Produces: Prisma
Mediamodel +MediaStatusenum. Fields:id: String(PK),userId: String,key: String,url: String,contentType: String,bytes: Int,status: MediaStatus(PENDING|ATTACHED),postId: String?,createdAt: DateTime,attachedAt: DateTime?. Relations:user → User(onDelete: Cascade),post → RMHark?(onDelete: SetNull).Step 1: Add the model + enum to the schema
Append after the RMHark model block in prisma/schema.prisma:
enum MediaStatus {
PENDING
ATTACHED
}
/// Developer-API uploaded media. The `media_id` is an opaque handle; posts
/// still store the resolved `url` in RMHark.imageUrls. Reclaimed by the
/// reconciling sweep in lib/media/sweep.server.ts.
model Media {
id String @id
userId String
key String
url String
contentType String
bytes Int
status MediaStatus @default(PENDING)
postId String?
createdAt DateTime @default(now())
attachedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post RMHark? @relation(fields: [postId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@index([postId])
@@index([userId])
}
Step 2: Add back-relations
In the User model (~line 12), add a relation field alongside its other [] relations:
media Media[]
In the RMHark model (~line 1138), add alongside its other relations (e.g. after views RMHarkView[]):
media Media[]
Step 3: Validate the schema
Run: pnpm exec prisma validate
Expected: The schema at prisma/schema.prisma is valid 🚀
Step 4: Create the migration and regenerate the client
Run: pnpm exec prisma migrate dev --name add_media_model
Expected: a new folder under prisma/migrations/ containing CREATE TABLE "Media" and CREATE TYPE "MediaStatus", and Generated Prisma Client printed. (If no shadow DB is available locally, run pnpm exec prisma db push instead and note the migration must be generated in CI.)
Step 5: Verify the generated type exists
Run: pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -5 then in a node REPL or a scratch import confirm import type { Media, MediaStatus } from '@prisma/client' resolves.
Expected: no error referencing Media.
Step 6: Commit
git add prisma/schema.prisma prisma/migrations
git commit -m "feat(media): add Media model and MediaStatus enum"
Task 2: media_id format helpers¶
Files:
Create:
lib/media/id.tsTest:
lib/__tests__/media-id.test.ts
Interfaces:
Produces:
MEDIA_ID_PREFIX: 'media_'newMediaId(): string— returnsmedia_+nanoid().isMediaId(v: unknown): v is string— true iff a non-empty string starting withMEDIA_ID_PREFIX.
Step 1: Write the failing test
lib/__tests__/media-id.test.ts:
import { describe, it, expect } from "vitest";
import { MEDIA_ID_PREFIX, newMediaId, isMediaId } from "@/lib/media/id";
describe("media id", () => {
it("newMediaId is prefixed and unique", () => {
const a = newMediaId();
const b = newMediaId();
expect(a.startsWith(MEDIA_ID_PREFIX)).toBe(true);
expect(a.length).toBeGreaterThan(MEDIA_ID_PREFIX.length);
expect(a).not.toBe(b);
});
it("isMediaId accepts our ids and rejects junk", () => {
expect(isMediaId(newMediaId())).toBe(true);
expect(isMediaId("media_")).toBe(true);
expect(isMediaId("nope")).toBe(false);
expect(isMediaId("")).toBe(false);
expect(isMediaId(null)).toBe(false);
expect(isMediaId(123)).toBe(false);
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/media-id.test.ts
Expected: FAIL — cannot find module @/lib/media/id.
Step 3: Write minimal implementation
lib/media/id.ts:
import { nanoid } from "nanoid";
export const MEDIA_ID_PREFIX = "media_";
/** Opaque, developer-facing media handle. */
export function newMediaId(): string {
return `${MEDIA_ID_PREFIX}${nanoid()}`;
}
export function isMediaId(v: unknown): v is string {
return typeof v === "string" && v.startsWith(MEDIA_ID_PREFIX);
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/media-id.test.ts
Expected: PASS (2 tests).
Step 5: Commit
git add lib/media/id.ts lib/__tests__/media-id.test.ts
git commit -m "feat(media): add media_id helpers"
Task 3: Upload + attach validation policy¶
Files:
Create:
lib/media/policy.tsTest:
lib/__tests__/media-policy.test.ts
Interfaces:
Consumes:
validateImageBuffer,detectImageExtfrom@/lib/slice-it/upload-validation.Produces:
MEDIA_MAX_BYTES = 5 * 1024 * 1024MAX_MEDIA_PER_POST = 4validateUpload(buffer: Buffer): { ok: true; ext: ".png" | ".jpg" | ".webp" | ".gif" } | { ok: false; error: string }attachError(media: { userId: string; status: string } | null, ownerId: string): string | null— returns an error message, ornullwhen the media is attachable.
Step 1: Write the failing test
lib/__tests__/media-policy.test.ts:
import { describe, it, expect } from "vitest";
import {
MEDIA_MAX_BYTES,
MAX_MEDIA_PER_POST,
validateUpload,
attachError,
} from "@/lib/media/policy";
// Minimal valid magic-byte buffers.
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]);
const JUNK = Buffer.from([0x00, 0x01, 0x02, 0x03]);
describe("validateUpload", () => {
it("accepts a png and returns its ext", () => {
expect(validateUpload(PNG)).toEqual({ ok: true, ext: ".png" });
});
it("rejects oversize buffers", () => {
const big = Buffer.alloc(MEDIA_MAX_BYTES + 1);
PNG.copy(big); // valid signature, but too large
const res = validateUpload(big);
expect(res.ok).toBe(false);
});
it("rejects unknown formats", () => {
const res = validateUpload(JUNK);
expect(res.ok).toBe(false);
});
});
describe("attachError", () => {
it("returns null when owned and pending", () => {
expect(attachError({ userId: "u1", status: "PENDING" }, "u1")).toBeNull();
});
it("rejects missing media", () => {
expect(attachError(null, "u1")).toMatch(/not found/i);
});
it("rejects foreign-owned media", () => {
expect(attachError({ userId: "u2", status: "PENDING" }, "u1")).toMatch(/not found/i);
});
it("rejects already-attached media", () => {
expect(attachError({ userId: "u1", status: "ATTACHED" }, "u1")).toMatch(/already/i);
});
it("MAX_MEDIA_PER_POST is 4", () => {
expect(MAX_MEDIA_PER_POST).toBe(4);
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/media-policy.test.ts
Expected: FAIL — cannot find module @/lib/media/policy.
Step 3: Write minimal implementation
lib/media/policy.ts:
import { validateImageBuffer, detectImageExt } from "@/lib/slice-it/upload-validation";
export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5 MB
export const MAX_MEDIA_PER_POST = 4;
export type ImageExt = ".png" | ".jpg" | ".webp" | ".gif";
export function validateUpload(
buffer: Buffer
): { ok: true; ext: ImageExt } | { ok: false; error: string } {
if (buffer.length > MEDIA_MAX_BYTES) {
return { ok: false, error: `Image too large. Maximum size is ${MEDIA_MAX_BYTES / 1024 / 1024} MB.` };
}
const valid = validateImageBuffer(buffer);
if (!valid.ok) return valid;
const ext = detectImageExt(buffer);
if (!ext) return { ok: false, error: "Unsupported image format." };
return { ok: true, ext };
}
/** Returns an error message if this media can't be attached by `ownerId`, else null. */
export function attachError(
media: { userId: string; status: string } | null,
ownerId: string
): string | null {
// Treat foreign-owned the same as missing — don't leak existence.
if (!media || media.userId !== ownerId) return "Media not found.";
if (media.status !== "PENDING") return "Media already attached to a post.";
return null;
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/media-policy.test.ts
Expected: PASS (5 tests).
Step 5: Commit
git add lib/media/policy.ts lib/__tests__/media-policy.test.ts
git commit -m "feat(media): add upload/attach validation policy"
Task 4: CDN purge abstraction¶
Files:
Create:
lib/storage/cdn.server.tsTest:
lib/__tests__/storage-cdn.test.ts
Interfaces:
Produces:
cdnConfigured(): boolean— true iffCDN_PURGE_URLandCDN_PURGE_TOKENare set.purgeFromCdn(key: string): Promise<void>— no-op when unconfigured; otherwise POSTs{ key }toCDN_PURGE_URLwith a bearer token. Best-effort: never throws (logs and swallows), so a purge failure can’t abort a sweep.
Step 1: Write the failing test
lib/__tests__/storage-cdn.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
beforeEach(() => {
vi.resetModules();
delete process.env.CDN_PURGE_URL;
delete process.env.CDN_PURGE_TOKEN;
vi.unstubAllGlobals();
});
describe("purgeFromCdn", () => {
it("is a no-op and does not fetch when unconfigured", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { cdnConfigured, purgeFromCdn } = await import("@/lib/storage/cdn.server");
expect(cdnConfigured()).toBe(false);
await purgeFromCdn("rmharks/a.png");
expect(fetchMock).not.toHaveBeenCalled();
});
it("POSTs the key with a bearer token when configured", async () => {
process.env.CDN_PURGE_URL = "https://cdn.example/purge";
process.env.CDN_PURGE_TOKEN = "secret";
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const { cdnConfigured, purgeFromCdn } = await import("@/lib/storage/cdn.server");
expect(cdnConfigured()).toBe(true);
await purgeFromCdn("rmharks/a.png");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://cdn.example/purge");
expect(init.method).toBe("POST");
expect(init.headers.Authorization).toBe("Bearer secret");
expect(JSON.parse(init.body)).toEqual({ key: "rmharks/a.png" });
});
it("swallows fetch errors (best-effort)", async () => {
process.env.CDN_PURGE_URL = "https://cdn.example/purge";
process.env.CDN_PURGE_TOKEN = "secret";
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
const { purgeFromCdn } = await import("@/lib/storage/cdn.server");
await expect(purgeFromCdn("rmharks/a.png")).resolves.toBeUndefined();
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/storage-cdn.test.ts
Expected: FAIL — cannot find module @/lib/storage/cdn.server.
Step 3: Write minimal implementation
lib/storage/cdn.server.ts:
/**
* CDN purge abstraction. Deleting an S3 origin object does not evict CDN edge
* caches, so cleanup must explicitly purge. This is a no-op until a CDN is
* configured (CDN_PURGE_URL + CDN_PURGE_TOKEN), keeping callers CDN-agnostic.
* Best-effort: a purge failure must never abort a sweep, so errors are logged
* and swallowed (a short Cache-Control TTL is the safety net).
*/
export function cdnConfigured(): boolean {
return Boolean(process.env.CDN_PURGE_URL && process.env.CDN_PURGE_TOKEN);
}
export async function purgeFromCdn(key: string): Promise<void> {
if (!cdnConfigured()) return;
try {
await fetch(process.env.CDN_PURGE_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.CDN_PURGE_TOKEN!}`,
},
body: JSON.stringify({ key }),
});
} catch (err) {
console.error("[cdn] purge failed for", key, err);
}
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/storage-cdn.test.ts
Expected: PASS (3 tests).
Step 5: Commit
git add lib/storage/cdn.server.ts lib/__tests__/storage-cdn.test.ts
git commit -m "feat(storage): add CDN purge abstraction"
Task 5: Upload service + POST /api/v1/images¶
Files:
Create:
lib/media/upload.server.tsCreate:
app/routes/api/v1/images.tsTest:
lib/__tests__/media-upload.test.ts
Interfaces:
Consumes:
newMediaId(Task 2);validateUpload(Task 3);feedImageKey,feedImageUrl,contentTypeForFilenamefrom@/lib/storage/keys;ORPHAN_TTL_MS(Task 6 — but defined inline here to avoid a forward dep; see note).Produces:
createMediaFromUpload(deps, args): Promise<{ id: string; expiresAt: Date }>deps:{ prisma: { media: { create(args): Promise<unknown> } }; putObject(key, body, contentType): Promise<void> }args:{ userId: string; buffer: Buffer; now?: Date }Throws
Errorwith a user-safe.messagewhenvalidateUploadfails.
Note on ORPHAN_TTL_MS: define the constant in Task 6’s lib/media/sweep-policy.ts first if executing in order; this task imports mediaExpiresAt from there. If executing out of order, Task 6’s file is tiny — create it first.
Step 1: Write the failing test
lib/__tests__/media-upload.test.ts:
import { describe, it, expect, vi } from "vitest";
import { createMediaFromUpload } from "@/lib/media/upload.server";
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]);
function makeDeps() {
const created: any[] = [];
const put: any[] = [];
return {
created,
put,
deps: {
prisma: { media: { create: vi.fn(async ({ data }: any) => { created.push(data); return data; }) } },
putObject: vi.fn(async (key: string, body: Buffer, ct: string) => { put.push({ key, body, ct }); }),
},
};
}
describe("createMediaFromUpload", () => {
it("validates, stores, writes a PENDING row, returns id + expiry", async () => {
const { deps, created, put } = makeDeps();
const now = new Date("2026-06-22T00:00:00.000Z");
const res = await createMediaFromUpload(deps, { userId: "u1", buffer: PNG, now });
expect(res.id).toMatch(/^media_/);
expect(res.expiresAt.toISOString()).toBe("2026-06-23T00:00:00.000Z"); // +24h
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({
id: res.id,
userId: "u1",
status: "PENDING",
contentType: "image/png",
bytes: PNG.length,
});
expect(created[0].key).toMatch(/^rmharks\/u1-/);
expect(created[0].url).toMatch(/^\/api\/feed\/image\/u1-/);
expect(put).toHaveLength(1);
expect(put[0].key).toBe(created[0].key);
expect(put[0].ct).toBe("image/png");
});
it("throws a user-safe error on an unsupported format", async () => {
const { deps } = makeDeps();
await expect(
createMediaFromUpload(deps, { userId: "u1", buffer: Buffer.from([0, 1, 2, 3]) })
).rejects.toThrow(/format|image/i);
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/media-upload.test.ts
Expected: FAIL — cannot find module @/lib/media/upload.server.
Step 3: Write minimal implementation
lib/media/upload.server.ts:
import { newMediaId } from "@/lib/media/id";
import { validateUpload } from "@/lib/media/policy";
import { mediaExpiresAt } from "@/lib/media/sweep-policy";
import { feedImageKey, feedImageUrl, contentTypeForFilename } from "@/lib/storage/keys";
export interface UploadDeps {
prisma: { media: { create(args: { data: Record<string, unknown> }): Promise<unknown> } };
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
}
export async function createMediaFromUpload(
deps: UploadDeps,
args: { userId: string; buffer: Buffer; now?: Date }
): Promise<{ id: string; expiresAt: Date }> {
const validated = validateUpload(args.buffer);
if (!validated.ok) throw new Error(validated.error);
const now = args.now ?? new Date();
const uniqueSuffix = `${now.getTime()}-${Math.round(Math.random() * 1e9)}`;
const filename = `${args.userId}-${uniqueSuffix}${validated.ext}`;
const key = feedImageKey(filename);
const url = feedImageUrl(filename);
const contentType = contentTypeForFilename(filename);
await deps.putObject(key, args.buffer, contentType);
const id = newMediaId();
await deps.prisma.media.create({
data: {
id,
userId: args.userId,
key,
url,
contentType,
bytes: args.buffer.length,
status: "PENDING",
createdAt: now,
},
});
return { id, expiresAt: mediaExpiresAt(now) };
}
If Task 6 isn’t done yet, create
lib/media/sweep-policy.tsnow with at leastORPHAN_TTL_MSandmediaExpiresAt(full content in Task 6).
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/media-upload.test.ts
Expected: PASS (2 tests).
Step 5: Add the route handler
app/routes/api/v1/images.ts:
import { createFileRoute } from "@tanstack/react-router";
import { prisma } from "@/lib/prisma.server";
import { putObject } from "@/lib/storage/s3.server";
import { withDeveloperApi, apiJson, apiError, apiOptions } from "@/lib/api/with-developer-api.server";
import { createMediaFromUpload } from "@/lib/media/upload.server";
import { MEDIA_MAX_BYTES } from "@/lib/media/policy";
/**
* POST /api/v1/images — upload one image, get an opaque media_id back.
* Attach it to a post via POST /api/v1/posts { media_ids: [...] }.
*/
export const Route = createFileRoute("/api/v1/images")({
server: {
handlers: {
OPTIONS: () => apiOptions(),
POST: ({ request }) =>
withDeveloperApi(request, async ({ userId }) => {
// Reject oversize bodies before reading them into memory.
const declared = Number(request.headers.get("content-length") ?? "0");
if (declared > MEDIA_MAX_BYTES) {
return apiError("payload_too_large", `Image too large. Maximum size is ${MEDIA_MAX_BYTES / 1024 / 1024} MB.`, 413);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return apiError("invalid_request", "Expected multipart/form-data with an `image` field.", 400);
}
const file = form.get("image");
if (!(file instanceof File) || file.size === 0) {
return apiError("invalid_request", "No image provided. Send one file in the `image` field.", 400);
}
if (file.size > MEDIA_MAX_BYTES) {
return apiError("invalid_request", `Image too large. Maximum size is ${MEDIA_MAX_BYTES / 1024 / 1024} MB.`, 400);
}
const buffer = Buffer.from(await file.arrayBuffer());
try {
const { id, expiresAt } = await createMediaFromUpload({ prisma, putObject }, { userId, buffer });
return apiJson({ id, type: "image", expires_at: expiresAt.toISOString() }, 201);
} catch (err) {
return apiError("invalid_request", err instanceof Error ? err.message : "Invalid image.", 400);
}
}),
},
},
});
Step 6: Regenerate the route tree and typecheck
Run: pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | grep -E "images|media" | head
Expected: no errors. (If the project regenerates routeTree.gen.ts via a dev/build step, run that — e.g. pnpm dev once or the configured generate command — so /api/v1/images is registered.)
Step 7: Manual smoke (with a dev server running and a real API key)
Run:
curl -s -X POST http://localhost:3000/api/v1/images \
-H "Authorization: Bearer rmh_live_<key>" \
-F "image=@./some.png"
Expected: 201 JSON like {"id":"media_...","type":"image","expires_at":"..."}.
Step 8: Commit
git add lib/media/upload.server.ts app/routes/api/v1/images.ts lib/__tests__/media-upload.test.ts app/routeTree.gen.ts
git commit -m "feat(api): add POST /api/v1/images upload endpoint"
Task 6: Sweep policy constants¶
Files:
Create:
lib/media/sweep-policy.tsTest:
lib/__tests__/media-sweep-policy.test.ts
Interfaces:
Produces:
ORPHAN_TTL_MS = 24 * 60 * 60 * 1000DELETED_POST_GRACE_MS = 7 * 24 * 60 * 60 * 1000mediaExpiresAt(createdAt: Date): Date—createdAt + ORPHAN_TTL_MS.orphanCutoff(now: Date): Date—now − ORPHAN_TTL_MS(PENDING older than this is orphaned).deletedPostCutoff(now: Date): Date—now − DELETED_POST_GRACE_MS.
Step 1: Write the failing test
lib/__tests__/media-sweep-policy.test.ts:
import { describe, it, expect } from "vitest";
import {
ORPHAN_TTL_MS,
DELETED_POST_GRACE_MS,
mediaExpiresAt,
orphanCutoff,
deletedPostCutoff,
} from "@/lib/media/sweep-policy";
const now = new Date("2026-06-22T00:00:00.000Z");
describe("sweep policy", () => {
it("constants are 24h and 7d", () => {
expect(ORPHAN_TTL_MS).toBe(86_400_000);
expect(DELETED_POST_GRACE_MS).toBe(604_800_000);
});
it("mediaExpiresAt adds 24h", () => {
expect(mediaExpiresAt(now).toISOString()).toBe("2026-06-23T00:00:00.000Z");
});
it("orphanCutoff subtracts 24h", () => {
expect(orphanCutoff(now).toISOString()).toBe("2026-06-21T00:00:00.000Z");
});
it("deletedPostCutoff subtracts 7d", () => {
expect(deletedPostCutoff(now).toISOString()).toBe("2026-06-15T00:00:00.000Z");
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/media-sweep-policy.test.ts
Expected: FAIL — cannot find module @/lib/media/sweep-policy.
Step 3: Write minimal implementation
lib/media/sweep-policy.ts:
export const ORPHAN_TTL_MS = 24 * 60 * 60 * 1000; // 24h
export const DELETED_POST_GRACE_MS = 7 * 24 * 60 * 60 * 1000; // 7d
/** When a never-attached upload becomes eligible for cleanup. */
export function mediaExpiresAt(createdAt: Date): Date {
return new Date(createdAt.getTime() + ORPHAN_TTL_MS);
}
/** PENDING media created before this is orphaned. */
export function orphanCutoff(now: Date): Date {
return new Date(now.getTime() - ORPHAN_TTL_MS);
}
/** Media whose post was soft-deleted before this is eligible for cleanup. */
export function deletedPostCutoff(now: Date): Date {
return new Date(now.getTime() - DELETED_POST_GRACE_MS);
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/media-sweep-policy.test.ts
Expected: PASS (4 tests).
Step 5: Commit
git add lib/media/sweep-policy.ts lib/__tests__/media-sweep-policy.test.ts
git commit -m "feat(media): add sweep policy constants"
Task 7: Attach service + wire media_ids into POST /api/v1/posts¶
Files:
Create:
lib/media/attach.server.tsModify:
app/routes/api/v1/posts.tsTest:
lib/__tests__/media-attach.test.ts
Interfaces:
Consumes:
attachError,MAX_MEDIA_PER_POST(Task 3);isMediaId(Task 2).Produces:
resolveMediaForPost(deps, args): Promise<{ ok: true; urls: string[] } | { ok: false; error: string }>deps:{ prisma: { media: { findMany(args): Promise<Array<{ id: string; url: string; userId: string; status: string }>> }; updateMany(args): Promise<{ count: number }> } }args:{ userId: string; mediaIds: string[]; postId: string; now?: Date }Validates count (≤
MAX_MEDIA_PER_POST), that every id is owned + PENDING (viaattachError), then flips them toATTACHEDwithpostId/attachedAtvia a guardedupdateMany(only rows stillPENDING), and returns the resolvedurlsin the input order. If the guarded update doesn’t flip every id (race), returns{ ok: false, error }.
Step 1: Write the failing test
lib/__tests__/media-attach.test.ts:
import { describe, it, expect, vi } from "vitest";
import { resolveMediaForPost } from "@/lib/media/attach.server";
function deps(rows: Array<{ id: string; url: string; userId: string; status: string }>) {
return {
prisma: {
media: {
findMany: vi.fn(async () => rows),
updateMany: vi.fn(async () => ({ count: rows.filter((r) => r.status === "PENDING").length })),
},
},
};
}
describe("resolveMediaForPost", () => {
it("resolves urls in input order and flips to ATTACHED", async () => {
const d = deps([
{ id: "media_b", url: "/api/feed/image/u1-2.png", userId: "u1", status: "PENDING" },
{ id: "media_a", url: "/api/feed/image/u1-1.png", userId: "u1", status: "PENDING" },
]);
const res = await resolveMediaForPost(d, { userId: "u1", mediaIds: ["media_a", "media_b"], postId: "p1" });
expect(res).toEqual({ ok: true, urls: ["/api/feed/image/u1-1.png", "/api/feed/image/u1-2.png"] });
expect(d.prisma.media.updateMany).toHaveBeenCalledTimes(1);
});
it("rejects more than 4 ids", async () => {
const d = deps([]);
const res = await resolveMediaForPost(d, {
userId: "u1",
mediaIds: ["media_1", "media_2", "media_3", "media_4", "media_5"],
postId: "p1",
});
expect(res).toEqual({ ok: false, error: expect.stringMatching(/at most 4/i) });
expect(d.prisma.media.findMany).not.toHaveBeenCalled();
});
it("rejects a foreign-owned id", async () => {
const d = deps([{ id: "media_a", url: "/x", userId: "u2", status: "PENDING" }]);
const res = await resolveMediaForPost(d, { userId: "u1", mediaIds: ["media_a"], postId: "p1" });
expect(res.ok).toBe(false);
});
it("rejects a missing id", async () => {
const d = deps([]); // findMany returns nothing
const res = await resolveMediaForPost(d, { userId: "u1", mediaIds: ["media_a"], postId: "p1" });
expect(res).toEqual({ ok: false, error: expect.stringMatching(/not found/i) });
});
it("rejects a non-media-id string", async () => {
const d = deps([]);
const res = await resolveMediaForPost(d, { userId: "u1", mediaIds: ["nope"], postId: "p1" });
expect(res.ok).toBe(false);
expect(d.prisma.media.findMany).not.toHaveBeenCalled();
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/media-attach.test.ts
Expected: FAIL — cannot find module @/lib/media/attach.server.
Step 3: Write minimal implementation
lib/media/attach.server.ts:
import { isMediaId } from "@/lib/media/id";
import { attachError, MAX_MEDIA_PER_POST } from "@/lib/media/policy";
interface MediaRow { id: string; url: string; userId: string; status: string }
export interface AttachDeps {
prisma: {
media: {
findMany(args: { where: { id: { in: string[] } } }): Promise<MediaRow[]>;
updateMany(args: {
where: { id: { in: string[] }; status: "PENDING" };
data: { status: "ATTACHED"; postId: string; attachedAt: Date };
}): Promise<{ count: number }>;
};
};
}
export async function resolveMediaForPost(
deps: AttachDeps,
args: { userId: string; mediaIds: string[]; postId: string; now?: Date }
): Promise<{ ok: true; urls: string[] } | { ok: false; error: string }> {
const { userId, mediaIds, postId } = args;
if (mediaIds.length === 0) return { ok: true, urls: [] };
if (mediaIds.length > MAX_MEDIA_PER_POST) {
return { ok: false, error: `At most ${MAX_MEDIA_PER_POST} images per post.` };
}
if (!mediaIds.every(isMediaId)) return { ok: false, error: "Media not found." };
const rows = await deps.prisma.media.findMany({ where: { id: { in: mediaIds } } });
const byId = new Map(rows.map((r) => [r.id, r]));
for (const id of mediaIds) {
const err = attachError(byId.get(id) ?? null, userId);
if (err) return { ok: false, error: err };
}
// Guarded flip: only rows still PENDING move. If the count doesn't match, a
// concurrent attach won the race for one of them.
const { count } = await deps.prisma.media.updateMany({
where: { id: { in: mediaIds }, status: "PENDING" },
data: { status: "ATTACHED", postId, attachedAt: args.now ?? new Date() },
});
if (count !== mediaIds.length) {
return { ok: false, error: "One or more media were already attached." };
}
// Preserve caller's order.
return { ok: true, urls: mediaIds.map((id) => byId.get(id)!.url) };
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/media-attach.test.ts
Expected: PASS (5 tests).
Step 5: Wire into the posts route
In app/routes/api/v1/posts.ts, update the imports and the POST handler. Change the schema and create flow:
Replace the createSchema (lines ~9-12) with:
const createSchema = z
.object({
content: z.string().max(MAX_RMHARK_LENGTH).optional(),
media_ids: z.array(z.string()).max(4).optional(),
audience: z.enum(["PUBLIC", "FOLLOWERS", "PRIVATE"]).optional(),
})
.refine((v) => (v.content?.trim().length ?? 0) > 0 || (v.media_ids?.length ?? 0) > 0, {
message: "A post needs text content or at least one image.",
});
Add the import near the top:
import { resolveMediaForPost } from "@/lib/media/attach.server";
Replace the body of the POST handler’s create logic (the prisma.rMHark.create block, lines ~57-66) with:
const content = parsed.data.content?.trim() ?? "";
const mediaIds = parsed.data.media_ids ?? [];
// Create the post first so we have its id to attach media to.
const post = await prisma.rMHark.create({
data: { userId, content, audience: parsed.data.audience ?? "PUBLIC" },
select: { id: true, content: true, createdAt: true, audience: true },
});
if (mediaIds.length > 0) {
const attached = await resolveMediaForPost({ prisma }, { userId, mediaIds, postId: post.id });
if (!attached.ok) {
// Roll back the just-created post so a bad media ref doesn't leave an empty post.
await prisma.rMHark.delete({ where: { id: post.id } }).catch(() => {});
return apiError("invalid_media", attached.error, 400);
}
await prisma.rMHark.update({ where: { id: post.id }, data: { imageUrls: attached.urls } });
}
await awardXp(userId, 25).catch(() => {});
await progressQuests(userId, "post").catch(() => {});
return apiJson(
{ id: post.id, content: post.content, audience: post.audience, createdAt: post.createdAt },
201
);
Step 6: Typecheck
Run: pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | grep -E "posts|attach|media" | head
Expected: no errors.
Step 7: Manual smoke (dev server + a real key + a media_id from Task 5)
Run:
curl -s -X POST http://localhost:3000/api/v1/posts \
-H "Authorization: Bearer rmh_live_<key>" -H "Content-Type: application/json" \
-d '{"content":"hi","media_ids":["media_..."]}'
Expected: 201 with the post; the post’s imageUrls now holds the resolved /api/feed/image/... URL, and the media row is ATTACHED.
Step 8: Commit
git add lib/media/attach.server.ts app/routes/api/v1/posts.ts lib/__tests__/media-attach.test.ts
git commit -m "feat(api): attach media_ids to posts in POST /api/v1/posts"
Task 8: Reconciling sweep + hourly trigger + docs¶
Files:
Create:
lib/media/sweep.server.tsModify:
server/recap/index.tsModify:
.env.example,docs/developer-api.mdTest:
lib/__tests__/media-sweep.test.ts
Interfaces:
Consumes:
orphanCutoff,deletedPostCutoff(Task 6).Produces:
sweepUnreferencedMedia(deps): Promise<{ deleted: number }>deps:{ prisma: { media: { findMany(args): Promise<Array<{ id: string; key: string }>>; deleteMany(args): Promise<{ count: number }> } }; deleteObject(key): Promise<void>; purgeFromCdn(key): Promise<void>; now?: Date }Selects media to reclaim in one
findManywith an OR of: (a)status=PENDINGandcreatedAt < orphanCutoff(now); (b)post.deletedAtnot null and< deletedPostCutoff(now). For each:deleteObject(key)thenpurgeFromCdn(key), then a singledeleteManyof all collected ids. Returns the deleted count.
Step 1: Write the failing test
lib/__tests__/media-sweep.test.ts:
import { describe, it, expect, vi } from "vitest";
import { sweepUnreferencedMedia } from "@/lib/media/sweep.server";
describe("sweepUnreferencedMedia", () => {
it("deletes objects, purges CDN, and removes rows for each target", async () => {
const targets = [
{ id: "media_1", key: "rmharks/a.png" },
{ id: "media_2", key: "rmharks/b.png" },
];
const deps = {
prisma: {
media: {
findMany: vi.fn(async () => targets),
deleteMany: vi.fn(async () => ({ count: targets.length })),
},
},
deleteObject: vi.fn(async () => {}),
purgeFromCdn: vi.fn(async () => {}),
now: new Date("2026-06-22T00:00:00.000Z"),
};
const res = await sweepUnreferencedMedia(deps);
expect(res).toEqual({ deleted: 2 });
expect(deps.deleteObject).toHaveBeenCalledWith("rmharks/a.png");
expect(deps.deleteObject).toHaveBeenCalledWith("rmharks/b.png");
expect(deps.purgeFromCdn).toHaveBeenCalledTimes(2);
expect(deps.prisma.media.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ["media_1", "media_2"] } },
});
// Sanity-check the selection query shape (OR of orphan + deleted-post).
const where = deps.prisma.media.findMany.mock.calls[0][0].where;
expect(Array.isArray(where.OR)).toBe(true);
expect(where.OR).toHaveLength(2);
});
it("is a no-op when nothing matches", async () => {
const deps = {
prisma: { media: { findMany: vi.fn(async () => []), deleteMany: vi.fn() } },
deleteObject: vi.fn(),
purgeFromCdn: vi.fn(),
};
const res = await sweepUnreferencedMedia(deps);
expect(res).toEqual({ deleted: 0 });
expect(deps.deleteObject).not.toHaveBeenCalled();
expect(deps.prisma.media.deleteMany).not.toHaveBeenCalled();
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/media-sweep.test.ts
Expected: FAIL — cannot find module @/lib/media/sweep.server.
Step 3: Write minimal implementation
lib/media/sweep.server.ts:
import { orphanCutoff, deletedPostCutoff } from "@/lib/media/sweep-policy";
interface Target { id: string; key: string }
export interface SweepDeps {
prisma: {
media: {
findMany(args: { where: unknown; select: { id: true; key: true } }): Promise<Target[]>;
deleteMany(args: { where: { id: { in: string[] } } }): Promise<{ count: number }>;
};
};
deleteObject(key: string): Promise<void>;
purgeFromCdn(key: string): Promise<void>;
now?: Date;
}
/**
* Reclaim media no longer referenced by a live post:
* (a) PENDING uploads never attached within the orphan TTL, and
* (b) media whose post was soft-deleted past the grace period.
* Self-healing — keys off post.deletedAt, not a delete-handler hook.
*/
export async function sweepUnreferencedMedia(deps: SweepDeps): Promise<{ deleted: number }> {
const now = deps.now ?? new Date();
const targets = await deps.prisma.media.findMany({
where: {
OR: [
{ status: "PENDING", createdAt: { lt: orphanCutoff(now) } },
{ post: { deletedAt: { not: null, lt: deletedPostCutoff(now) } } },
],
},
select: { id: true, key: true },
});
if (targets.length === 0) return { deleted: 0 };
for (const t of targets) {
await deps.deleteObject(t.key);
await deps.purgeFromCdn(t.key);
}
await deps.prisma.media.deleteMany({ where: { id: { in: targets.map((t) => t.id) } } });
return { deleted: targets.length };
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/media-sweep.test.ts
Expected: PASS (2 tests).
Step 5: Wire the hourly trigger into the recap worker
In server/recap/index.ts, add imports near the other lib imports:
import { sweepUnreferencedMedia } from "../../lib/media/sweep.server";
import { deleteObject } from "../../lib/storage/s3.server";
import { purgeFromCdn } from "../../lib/storage/cdn.server";
Inside main(), after the existing recap setInterval is set up (~line 280), add an hourly sweep that reuses the worker’s own prisma client:
const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1h
const runMediaSweep = () =>
sweepUnreferencedMedia({ prisma: prisma as any, deleteObject, purgeFromCdn })
.then((r) => { if (r.deleted) log(`Media sweep removed ${r.deleted} object(s).`); })
.catch((e) => log(`Media sweep error: ${e}`));
const mediaSweepInterval = setInterval(runMediaSweep, MEDIA_SWEEP_INTERVAL_MS);
runMediaSweep(); // run once on boot
And in the existing shutdown handler (~line 290, alongside clearInterval(interval)), add:
clearInterval(mediaSweepInterval);
Step 6: Document env vars and the endpoint
In .env.example, add a CDN section:
# Optional CDN cache purge for media cleanup. When set, the media sweep purges
# deleted objects from the CDN edge. Leave unset to skip (S3 origin delete only).
CDN_PURGE_URL=
CDN_PURGE_TOKEN=
In docs/developer-api.md, under ## Endpoints (after the POST /api/v1/posts section ~line 152), add:
### `POST /api/v1/images`
Upload one image and receive an opaque `media_id` to attach to a post. Send
`multipart/form-data` with a single `image` field. Max 5 MB; png/jpg/webp/gif.
```bash
curl -X POST https://rmhstudios.com/api/v1/images \
-H "Authorization: Bearer rmh_live_..." \
-F "image=@./photo.png"
# → 201 { "id": "media_...", "type": "image", "expires_at": "..." }
```
Unattached media expires ~24h after upload. Attach within that window:
```bash
curl -X POST https://rmhstudios.com/api/v1/posts \
-H "Authorization: Bearer rmh_live_..." -H "Content-Type: application/json" \
-d '{"content":"hello","media_ids":["media_..."]}'
```
`POST /api/v1/posts` now accepts `media_ids` (array, max 4). `content` is
optional when at least one `media_id` is supplied.
Also add a line under ## Changelog (~line 186).
Step 7: Typecheck the worker + full test run
Run: pnpm exec tsc --noEmit -p tsconfig.server.json 2>&1 | grep -E "recap|sweep|media" | head
Expected: no errors.
Run: pnpm exec vitest run lib/__tests__/media-*.test.ts lib/__tests__/storage-cdn.test.ts
Expected: all PASS.
Step 8: Commit
git add lib/media/sweep.server.ts server/recap/index.ts .env.example docs/developer-api.md lib/__tests__/media-sweep.test.ts
git commit -m "feat(media): reconciling sweep + hourly worker trigger + docs"
Task 9: Upload abuse controls (capability gate + tight limit + daily quota)¶
Hardens the POST /api/v1/images route from Task 5. Until this task lands, the
endpoint is still gated to starter+ at 120/min (via withDeveloperApi +
hasApiAccess) and 413-guarded, but not yet tier-capability-gated, tight-limited, or
quota-capped.
Files:
Modify:
lib/entitlements.ts(addhasApiImageUpload)Create:
lib/media/quota.server.tsModify:
app/routes/api/v1/images.ts(wire the three controls)Test:
lib/__tests__/entitlements-image-upload.test.ts,lib/__tests__/media-quota.test.ts
Interfaces:
Consumes:
Tier,TIER_RANKfrom@/lib/entitlements;redisRateLimitfrom@/lib/redis.server;rateLimitfrom@/lib/rate-limit.Produces:
hasApiImageUpload(tier: Tier): boolean— true for starter and above.DAILY_UPLOAD_QUOTA: Record<Tier, number>—{ free: 0, starter: 200, pro: 1000, enterprise: 5000 }.keyedLimit(key: string, max: number, windowMs: number): Promise<{ allowed: boolean; retryAfter: number }>— Redis limiter with in-process fallback (same composition aswithDeveloperApi’s privatelimit).checkDailyUploadQuota(deps, args): Promise<{ allowed: boolean; retryAfter: number }>—deps: { limit: typeof keyedLimit },args: { userId: string; tier: Tier }. Callslimit(\media-quota:${userId}`, DAILY_UPLOAD_QUOTA[tier], 24h)`.
Step 1: Write the failing entitlements test
lib/__tests__/entitlements-image-upload.test.ts:
import { describe, it, expect } from "vitest";
import { hasApiImageUpload } from "@/lib/entitlements";
describe("hasApiImageUpload", () => {
it("grants starter and above, denies free", () => {
expect(hasApiImageUpload("free")).toBe(false);
expect(hasApiImageUpload("starter")).toBe(true);
expect(hasApiImageUpload("pro")).toBe(true);
expect(hasApiImageUpload("enterprise")).toBe(true);
});
});
Step 2: Run it (fails), then add the capability
Run: pnpm exec vitest run lib/__tests__/entitlements-image-upload.test.ts
Expected: FAIL — hasApiImageUpload is not exported.
In lib/entitlements.ts, add next to hasApiAccess:
/** Image upload via the developer API — starter and above. */
export function hasApiImageUpload(tier: Tier): boolean {
return TIER_RANK[tier] >= TIER_RANK.starter;
}
Run again — Expected: PASS.
Step 3: Write the failing quota test
lib/__tests__/media-quota.test.ts:
import { describe, it, expect, vi } from "vitest";
import { DAILY_UPLOAD_QUOTA, checkDailyUploadQuota } from "@/lib/media/quota.server";
describe("daily upload quota", () => {
it("quota map matches the spec", () => {
expect(DAILY_UPLOAD_QUOTA).toEqual({ free: 0, starter: 200, pro: 1000, enterprise: 5000 });
});
it("limits per user with the tier's max over a 24h window", async () => {
const limit = vi.fn(async () => ({ allowed: true, retryAfter: 0 }));
const res = await checkDailyUploadQuota({ limit }, { userId: "u1", tier: "pro" });
expect(res.allowed).toBe(true);
expect(limit).toHaveBeenCalledWith("media-quota:u1", 1000, 24 * 60 * 60 * 1000);
});
it("propagates a deny", async () => {
const limit = vi.fn(async () => ({ allowed: false, retryAfter: 3600 }));
const res = await checkDailyUploadQuota({ limit }, { userId: "u1", tier: "starter" });
expect(res).toEqual({ allowed: false, retryAfter: 3600 });
expect(limit).toHaveBeenCalledWith("media-quota:u1", 200, 24 * 60 * 60 * 1000);
});
});
Step 4: Run it (fails), then implement
Run: pnpm exec vitest run lib/__tests__/media-quota.test.ts
Expected: FAIL — cannot find module @/lib/media/quota.server.
lib/media/quota.server.ts:
import type { Tier } from "@/lib/entitlements";
import { rateLimit } from "@/lib/rate-limit";
import { redisRateLimit } from "@/lib/redis.server";
const DAY_MS = 24 * 60 * 60 * 1000;
export const DAILY_UPLOAD_QUOTA: Record<Tier, number> = {
free: 0,
starter: 200,
pro: 1000,
enterprise: 5000,
};
/** Cross-instance limiter (Redis) with per-instance fallback. */
export async function keyedLimit(
key: string,
max: number,
windowMs: number
): Promise<{ allowed: boolean; retryAfter: number }> {
const viaRedis = await redisRateLimit(key, max, windowMs);
if (viaRedis) return viaRedis;
return rateLimit(key, { limit: max, windowMs });
}
export interface QuotaDeps {
limit: (key: string, max: number, windowMs: number) => Promise<{ allowed: boolean; retryAfter: number }>;
}
export async function checkDailyUploadQuota(
deps: QuotaDeps,
args: { userId: string; tier: Tier }
): Promise<{ allowed: boolean; retryAfter: number }> {
return deps.limit(`media-quota:${args.userId}`, DAILY_UPLOAD_QUOTA[args.tier], DAY_MS);
}
Run again — Expected: PASS (3 tests).
Step 5: Wire the three controls into the route
In app/routes/api/v1/images.ts, update the imports:
import { hasApiImageUpload } from "@/lib/entitlements";
import { keyedLimit, checkDailyUploadQuota } from "@/lib/media/quota.server";
Change the handler signature to also destructure tier, and insert the gate, tight
limit, and quota checks immediately inside the withDeveloperApi callback — before
the 413 guard:
POST: ({ request }) =>
withDeveloperApi(request, async ({ userId, tier }) => {
// 1. Tier capability gate.
if (!hasApiImageUpload(tier)) {
return apiError("feature_not_available", "Image upload requires a Starter plan or higher.", 403);
}
// 2. Dedicated tight per-key limit (far below the generic 120/min).
const burst = await keyedLimit(`dev-api-image:${userId}`, 15, 60_000);
if (!burst.allowed) {
return apiError("rate_limited", "Too many uploads. Slow down.", 429, { "Retry-After": String(burst.retryAfter) });
}
// 3. Tier-scaled daily quota.
const quota = await checkDailyUploadQuota({ limit: keyedLimit }, { userId, tier });
if (!quota.allowed) {
return apiError("quota_exceeded", "Daily image upload limit reached.", 429, { "Retry-After": String(quota.retryAfter) });
}
// 4. Reject oversize bodies before reading them into memory.
const declared = Number(request.headers.get("content-length") ?? "0");
// ... (rest of the handler unchanged)
Step 6: Typecheck + run the abuse-control tests
Run: pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | grep -E "images|quota|entitle" | head
Expected: no errors.
Run: pnpm exec vitest run lib/__tests__/entitlements-image-upload.test.ts lib/__tests__/media-quota.test.ts
Expected: all PASS.
Step 7: Manual smoke
Run (16th call within a minute on one key):
for i in $(seq 1 16); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/images \
-H "Authorization: Bearer rmh_live_<key>" -F "image=@./some.png"
done
Expected: the first 15 return 201, the 16th returns 429.
Step 8: Commit
git add lib/entitlements.ts lib/media/quota.server.ts app/routes/api/v1/images.ts lib/__tests__/entitlements-image-upload.test.ts lib/__tests__/media-quota.test.ts
git commit -m "feat(api): tier-gate, tight-limit, and daily-quota image upload"
Self-Review Notes (for the implementer)¶
Spec coverage: upload endpoint (Task 5), opaque media_id (Task 2), attach into posts with URL storage = backward compat (Task 7), Media model (Task 1), single-use + orphan TTL (Tasks 3/6/8), deleted-post cleanup with 7-day grace (Tasks 6/8), CDN abstraction (Task 4 + Task 8 wiring), limits/magic-byte validation (Task 3), abuse controls — starter+ gate, 413 guard, 15/min limit, tier-scaled daily quota (Tasks 5/9), tests per the spec’s Testing section (every task).
Backward compat is verified structurally: posts store resolved URLs in
imageUrlsexactly as the in-app uploader does (Task 7 writesattached.urls), so existing posts and the feed renderer are untouched. No migration of existing rows.Confirm during Task 1: exact back-relation placement in
User/RMHark(they have many relation fields; addmedia Media[]to each).Route-tree regeneration:
app/routeTree.gen.tsis generated. If your build doesn’t auto-run it, run the project’s generate/dev step before the manual smoke tests in Tasks 5 and 7.