Enforcing a submission deadline at the object storage layer

Object storage has no clock. Here's where the time gate really belongs, how to clamp a presigned upload policy to the deadline, and how to prove nothing slipped in late.

Object storage doesn’t refuse writes on a schedule, and Infrai’s storage API is no exception — there’s no bucket policy surface that says “reject after Friday 23:59”. So the deadline can’t be enforced by the bucket. It’s enforced by the only thing that can create a usable write credential, which is your own code, and then proved afterwards from the metadata the bucket does keep.

That sounds like a downgrade. It isn’t, as long as you close the one gap everybody misses.

The gap: a credential minted before the deadline still works after it

A student clicks upload at 23:58. Your API checks the clock, sees the deadline is two minutes away, mints an upload slot with the usual 10-minute expiry and hands it over. At 00:04 that slot is still valid, and the storage host has no idea a deadline existed. The gate held at the door and leaked through a window.

The fix is one line: clamp the credential’s lifetime to the time remaining.

Enforcement pointEnforced byCatchesMisses
Your API route refuses to mintyour server clockevery request after the deadlineslots minted just before it
Upload policy expiry clamped to the deadlinethe storage host’s signature checklate use of an early slotclock skew of a few seconds
Post-deadline audit on last_modifiedobject metadataanything that got through anywaynothing — it’s the receipt
Bucket policy with a date conditionthe storage vendoreverything, server-sidenot part of this API

That last row is the honest one. Amazon S3 lets you write a bucket policy with a DateLessThan condition on aws:CurrentTime, so the deadline lives in the storage layer itself and no application bug can defeat it. If your requirement is literally “the storage layer must refuse it” — an academic-integrity rule that has to survive a compromised app server — you’d be better off there, and you should say so to whoever wrote the requirement — MinIO gives you the same policy surface if the university insists on its own hardware. For a course tool where the app server is the trusted component, the three rows above it are equivalent in practice and much simpler to operate.

Clamping the upload slot

The student’s PDF is posted to your route, and your route is what writes it to the bucket. That’s the shape to build here: uploads relay through your server, so the deadline check and the write happen in the same place and there’s no credential in the wild to leak past it.

There is still a slot to clamp, though, and it’s the one people forget: any upload credential you issue to something that isn’t your own server — a lab machine’s batch uploader, a CLI a TA runs, a worker draining a queue of late-arriving scans. POST /v1/storage/object/presign/{bucket}/{key} with op: "put" mints one, and the deadline goes into expires_seconds alongside the size and type conditions, so all three are checked by the storage host rather than by your validation code after the fact.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-submissions-0726/cs401/hw3/s_10422/report.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":90,"content_type":"application/pdf","max_bytes":20971520}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/",
    "method": "POST",
    "headers": null,
    "fields": {
      "Content-Type": "application/pdf",
      "key": "a4ee0c441fa36c267.kb-submissions-0726/cs401/hw3/s_10422/report.pdf",
      "x-amz-algorithm": "AWS4-HMAC-SHA256",
      "x-amz-credential": "IKID64Pnt5C96uXhswpZMzQlxBJk399IKBrN/20260727/ap-singapore/s3/aws4_request",
      "x-amz-date": "20260727T121406Z",
      "policy": "eyJleHBpcmF0aW9uIjogIjIwMjYtMDctMjdUMTI6MTU6MzZaIiwgImNvbmRpdGlvbnMi…",
      "x-amz-signature": "1b46c281d4bf39c83991ec41ec5ab8c9db753dde8b1a04a8577e6f112237eed3"
    },
    "expires_at": "2026-07-27T12:15:36.309101Z",
    "max_bytes": 20971520
  }
}

Base64-decode that policy and the first key is expiration, followed by a content-length-range and the pinned Content-Type. Deadlines are stored in UTC and read from the server; a client-supplied timestamp is a suggestion.

import express from "express";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "kb-submissions-0726";
const DEADLINES = new Map([["cs401/hw3", Date.parse("2026-07-31T23:59:00Z")]]);
const MAX_SLOT_SECONDS = 600;
const MAX_BYTES = 20 * 1024 * 1024;

const app = express();
app.use(express.json());

app.post("/api/submissions/:course/:unit/slot", async (req, res) => {
  const assignment = `${req.params.course}/${req.params.unit}`;
  const deadline = DEADLINES.get(assignment);
  if (!deadline) return res.status(404).json({ error: "unknown assignment" });

  const student = req.header("x-student-id");
  if (!student) return res.status(401).json({ error: "unauthenticated" });

  const remainingMs = deadline - Date.now();
  if (remainingMs <= 0) {
    return res.status(409).json({ error: "deadline_passed", deadline: new Date(deadline).toISOString() });
  }

  // The slot can never outlive the deadline it was issued under.
  const ttl = Math.max(1, Math.min(MAX_SLOT_SECONDS, Math.floor(remainingMs / 1000)));
  const objectKey = `${assignment}/s_${student}/report.pdf`;

  const upstream = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ op: "put", expires_seconds: ttl, content_type: "application/pdf", max_bytes: MAX_BYTES }),
  });
  const json = await upstream.json();
  if (!upstream.ok || !json.ok) {
    return res.status(502).json({ error: "presign_failed", status: upstream.status });
  }

  res.json({ url: json.data.url, fields: json.data.fields, expires_at: json.data.expires_at, key: objectKey });
});

app.listen(3000);

A slot issued at 23:58:30 now expires at 23:59:00 exactly, so the batch uploader holding it can’t drain a backlog into the bucket at ten past midnight. Late use is refused by the storage host, and an expired signature on the download side answers STORAGE_PRESIGN_EXPIRED.

For the student-facing path, the same clock check guards a plain relay — the route accepts the file, refuses after the deadline, and writes it with PUT /v1/storage/object/put/{bucket}/{key}. A 20 MB PDF through your own handler is unremarkable; if the requirement were 500 MB video submissions going page-to-bucket with no server in the middle, that’s what R2 or S3 with a CORS policy is for, and it’s worth saying so before you build.

The receipt: audit what actually landed

head records last_modified per object, which is the bucket’s own account of when the bytes arrived. Run this after the deadline and you have an answer for the first student who claims their upload was on time.

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "kb-submissions-0726";
const PREFIX = "cs401/hw3/";
const DEADLINE = Date.parse("2026-07-31T23:59:00Z");

async function call(path) {
  const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${KEY}` } });
  const json = await res.json();
  if (!res.ok || !json.ok) throw new Error(`${path}: HTTP ${res.status}`);
  return json.data;
}

const late = [];
const onTime = [];
let cursor = null;
do {
  const q = new URLSearchParams({ prefix: PREFIX, limit: "1000" });
  if (cursor) q.set("cursor", cursor);
  const page = await call(`/v1/storage/object/list/${BUCKET}?${q}`);
  for (const item of page.items ?? []) {
    const head = await call(`/v1/storage/object/head/${BUCKET}/${item.key}`);
    const arrived = Date.parse(head.last_modified);
    (arrived > DEADLINE ? late : onTime).push({ key: item.key, arrived: head.last_modified, bytes: head.size_bytes });
  }
  cursor = page.next_cursor ?? null;
} while (cursor);

console.log(`on time: ${onTime.length}, late: ${late.length}`);
for (const row of late) console.log("LATE", row.key, row.arrived);

list and head are both free, so running this on every assignment costs nothing. Use last_modified for anything you’ll defend in a grade appeal — it’s the write timestamp the store itself keeps.

Enumerate submitters without downloading anything

Pass delimiter=/ and the listing collapses to one entry per student folder.

curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-submissions-0726?prefix=cs401/hw3/&delimiter=/" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [],
    "next_cursor": null,
    "common_prefixes": ["cs401/hw3/s_10422/", "cs401/hw3/s_10891/"]
  }
}

Diff that against your enrolment list and you have the non-submitters in one call.

Sealing the set after the bell

Freezing a prefix isn’t something the bucket does for you, so make an explicit copy into a location your upload path can’t write to. POST /v1/storage/object/copy takes src_bucket, src_key, dst_bucket and dst_key, and bills as one write at $0.0001 per call, read on 27 July 2026.

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/copy" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"src_bucket":"kb-submissions-0726","src_key":"cs401/hw3/s_10422/report.pdf","dst_bucket":"kb-submissions-0726","dst_key":"sealed/cs401/hw3/s_10422/report.pdf"}'

The sealing step is where the single-credential argument pays off. Before you copy, POST /v1/pdf/watermark — free within rate limits, same Authorization header, no second vendor and no second account — stamps the received timestamp onto the page itself, so the sealed copy carries its own provenance instead of relying on a database row nobody can see during an appeal. POST /v1/pdf/parse on the same key tells you whether the file is a real PDF with pages in it rather than a renamed empty document, which is the other thing you find out too late. A storage-only vendor makes both of those a second procurement exercise.

Sealing also gives you somewhere to point a retention rule. POST /v1/storage/bucket/set_lifecycle/{bucket} takes prefix rules with expire_days, and the submitted list replaces the previous one entirely — keep the whole policy in one place and apply it from a deploy step. A rule on cs401/ with a 400-day expiry clears working copies after the appeal window while sealed/ stays untouched, because it appears in no rule at all. Anything you might have to produce at an academic integrity hearing should never be reachable by a policy whose job is deletion.

curl -sS "https://api.infrai.cc/v1/discovery?namespace=storage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Presign, list, head, lifecycle and bucket administration are free and rate-limited; object writes bill per call and reads bill by egress volume. Read the current block rather than this paragraph — rates drift downward and discount campaigns run.

Caveats before you build this

Clock skew is real but small. Both your server and the storage host work in UTC, and a policy clamped to the second will occasionally reject an upload that started at 23:58:59.7; give the deadline a documented grace of a minute in the syllabus rather than fighting it in code.

Treat an upload slot as a timer, not a lock. It names the exact object path it can write, which is good, and anyone holding it can use it until it expires, which is why the TTL clamp matters and why the “is this student enrolled in this course” check belongs in the route that mints it. The signature can’t make that decision for you.

And the bucket still has no clock of its own. If the deadline has to hold even when your application is wrong, that’s the one requirement this design can’t meet, and it’s the reason the S3 row in the table above is there.

References

Browse more storage developer guides