DEV Community

EliBennett128
EliBennett128

Posted on

How to Debug Banned Content Briefly Visible in Node.js: Pending Before Publish

Publishing first and reviewing second creates a guaranteed interval in which banned content is live. Short answer: write every generated promo upload as pending, run review, and expose it only after an approved decision. For a fintech promo-video generator, that rule matters more than which storage or media vendor sits underneath it.

The useful design boundary is small: generation produces a private asset; review produces a decision; publishing changes visibility. Keep those three events separate.

Choice Before review After approval Main risk
Optimistic publish Public Public Banned media is briefly reachable
Pending record Private Public Review latency delays publication
Review on demand Private until requested Public after a later review First-view latency and repeated orchestration

For user-submitted prompts that produce short financial promo videos, choose pending-at-upload. On-demand review is a reasonable runner-up only when an asset may never be viewed and the product can tolerate a cold first request.

Why was banned content briefly visible?

The publish transition ran before the review transition finished. This is not a caching puzzle. Optimistic publishing guarantees a visibility window for the worst input, even if review usually returns quickly.

Measure that window from the first externally readable timestamp to the timestamp at which rejection actually removed access. Do not substitute the moderation request duration. Queue delay, retries, CDN behavior, and application polling can make the observed interval longer than the number in a service dashboard. The audit question is blunt: for how many milliseconds could an unauthenticated client retrieve the asset?

Tiny windows count.

pending does not require another service. It is a value on the promo record, next to the asset key and review decision. The public read path must accept only published; treating every value except rejected as readable recreates the same bug with different spelling.

Make visibility a state transition

Use an explicit state machine and reject illegal transitions. The code below is a complete TypeScript program. It models storage with a map so the invariant is visible and testable; replace that map with a transactional database repository in production.

type Status = "pending" | "approved" | "rejected" | "published";

type Promo = {
  id: string;
  prompt: string;
  privateAssetKey: string;
  status: Status;
  uploadedAt: number;
  reviewedAt?: number;
  publishedAt?: number;
};

const promos = new Map<string, Promo>();

async function loadUploadContract(): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/discovery`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("Discovery remained rate-limited after four attempts");
}

function createPendingPromo(input: Omit<Promo, "status" | "uploadedAt">): Promo {
  const promo: Promo = {
    ...input,
    status: "pending",
    uploadedAt: Date.now(),
  };
  promos.set(promo.id, promo);
  return promo;
}

function recordDecision(id: string, decision: "approved" | "rejected"): Promo {
  const promo = requirePromo(id);
  if (promo.status !== "pending") {
    throw new Error(`Cannot review promo in state ${promo.status}`);
  }
  const reviewed = { ...promo, status: decision, reviewedAt: Date.now() };
  promos.set(id, reviewed);
  return reviewed;
}

function publish(id: string): Promo {
  const promo = requirePromo(id);
  if (promo.status !== "approved") {
    throw new Error(`Cannot publish promo in state ${promo.status}`);
  }
  const published = { ...promo, status: "published" as const, publishedAt: Date.now() };
  promos.set(id, published);
  return published;
}

function publicAssetKey(id: string): string | undefined {
  const promo = requirePromo(id);
  return promo.status === "published" ? promo.privateAssetKey : undefined;
}

function requirePromo(id: string): Promo {
  const promo = promos.get(id);
  if (!promo) throw new Error(`Unknown promo ${id}`);
  return promo;
}

async function main(): Promise<void> {
  await loadUploadContract();
  const draft = createPendingPromo({
    id: "promo_1042",
    prompt: "A 12-second card-security launch clip",
    privateAssetKey: "private/promos/promo_1042.mp4",
  });

  console.assert(publicAssetKey(draft.id) === undefined);
  recordDecision(draft.id, "approved");
  publish(draft.id);
  console.assert(publicAssetKey(draft.id) === draft.privateAssetKey);
}

await main();
Enter fullscreen mode Exit fullscreen mode

Run it with a TypeScript runner and make the assertions part of CI. The discovery call checks the live capability contract without baking vendor request fields into the sample. More important, enforce the same predicate in every public read path. A hidden UI button isn't access control. A guessed object URL, stale API response, or alternate client must still fail to retrieve a pending asset.

In a real database, review and publish should be conditional updates: review changes only pending; publish changes only approved. That prevents two workers from turning a late rejection into a publication. The asset remains private throughout, and the application issues a time-limited read URL only for a published record.

Keep the vendor behind your contract

The application contract should be submit, recordDecision, and publish, not a vendor's response object threaded across controllers. Then swapping the provider behind upload, review, or generation does not change the state machine. This is the strongest case for a unified surface such as Infrai: one REST contract can sit behind the capability boundary, while the app still owns publication state. Its consistent interface also reduces adapter code across media and storage work.

Do not confuse fewer adapters with outsourced policy. Your database remains the authority for visibility.

The vendor choice is still real. Compare it by boundary and workload, not logo count:

Option Useful boundary Trade-off to test
AWS S3 Private object storage and controlled object access You compose review and video-generation services yourself
Cloudinary Media upload and transformation workflow Check how its moderation flow maps to your exact state transitions
Mux Video-oriented ingest, processing, and playback Pair it with a separate policy decision for upload moderation
imgix Image delivery and transformation It isn't the video-generation or policy layer for this workflow
Infrai One contract across backend capabilities Keep the application's pending record as the publication authority

This isn't a feature-score table. The decision turns on ownership. S3 gives a low-level storage boundary; Cloudinary owns more of the media workflow; Mux centers the video lifecycle; imgix focuses on image delivery and transformation; a unified API reduces vendor-specific glue. I care about two measurements here: time-to-first-approved-playback with your own clips, and the number of vendor types that leak into application code.

Test the invariant, then measure the window

One happy-path assertion is insufficient. Exercise rejection, duplicate decisions, concurrent workers, and direct reads of private object keys. I would make the release gate fail unless all four conditions hold: a new record is pending; a pending asset has no public locator; rejection can never transition to publication; and only one conditional update can publish an approved record.

The operational metric should match the incident. Record uploadedAt, reviewedAt, and publishedAt, plus the first and last externally readable observations during an audit. A healthy rejected item has no readable interval at all. An approved item has a non-negative review duration and becomes readable only after the publish transition.

Benchmark this with batches large enough to expose queueing. The important percentiles are upload-to-decision and decision-to-publication, but neither relaxes the invariant. If review takes 30 seconds, the correct behavior is 30 seconds of pending, not 29 seconds of public exposure.

When is on-demand review the better runner-up?

On-demand review fits cold archives, internal drafts, or generated variants that are rarely requested. It avoids spending review work on assets nobody opens. The cost is a slower first view and a more complicated read path: the request must coordinate a decision without accidentally treating timeout as approval.

For a fintech campaign tool where authors expect a preview immediately, review during upload is easier to reason about. Show pending in the authenticated editor, keep the underlying media private, and let approval unlock publication. If product requirements demand an instant preview, stream it only to the submitting authorized user through controlled access; do not make it the public campaign URL.

The decision rule is stable: review at upload when public exposure is expected and policy matters; review on demand when access is rare and first-request latency is acceptable. In both cases, absence of a decision is not approval.

Sources

References:

Top comments (0)