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 presigned upload URLs 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, no per-object condition, nothing that says “reject after Friday 23:59”. So the deadline can’t be enforced by the bucket. It has to be enforced by the only thing that can create a usable write credential, which is your own code, and then proved afterwards from the object 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 a presigned PUT with the usual 10-minute expiry and hands it over. At 00:04 that URL 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 signature’s lifetime to the time remaining.
| Enforcement point | Enforced by | Catches | Misses |
|---|---|---|---|
| Your API route refuses to mint | your server clock | every request after the deadline | slots minted just before it |
| Presign TTL clamped to the deadline | the storage host’s signature check | late use of an early slot | clock skew of a few seconds |
Post-deadline audit on last_modified | object metadata | anything that got through anyway | nothing — it’s the receipt |
| Bucket policy with a date condition | the storage vendor | everything, server-side | not available on Infrai today |
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 policy that has to survive a compromised app server, say — you’d be better off on S3 with a policy, and you should say so to whoever wrote the requirement. For a course tool where the app server is the trusted component, the three rows above it are equivalent in practice and far simpler to operate.
Clamping the upload slot
Deadlines are stored in UTC and read from the server, never the browser. 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 app = express();
app.use(express.json());
app.post("/api/submissions/:assignment/slot", async (req, res) => {
const assignment = req.params.assignment;
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 }),
});
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, method: json.data.method ?? "PUT", 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. Late use is refused by the storage host with a 403, which we verified against the live API — an expired signature is rejected, and a signature with one character altered is rejected too.
Here’s the same presign on its own, so you can watch expires_at move:
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}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-submissions-0726/cs401/hw3/s_10422/report.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=90&X-Amz-Signature=c017e8372be6bd",
"method": "PUT",
"headers": null,
"fields": null,
"expires_at": "2026-07-26T00:49:11.254599Z",
"max_bytes": null
}
}
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. Worth flagging one quirk we hit: created_at in a list response looks stamped at listing time rather than at write time, so use last_modified for anything you’ll defend in a grade appeal.
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, preserves content type and metadata, and bills as one write.
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"}'
Copy is metered at $0.0001 per call, verified 26 July 2026, so sealing a 200-student cohort costs about two cents. Presign, list, head, lifecycle and bucket calls are free and rate-limited. Read the current figures rather than this sentence:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Rates drift downward and discount campaigns run, so your number may well be lower. New accounts start with $2 of free credit, which covers a semester of a small course outright.
Sealing also gives you somewhere to point a retention rule. POST /v1/storage/bucket/set_lifecycle/{bucket} takes a list of prefix rules with expire_days, and the submitted list replaces the previous one entirely — so keep the whole policy in one place and apply it from a deploy step rather than editing it by hand. 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 for an academic integrity hearing should never be reachable by a policy whose job is deletion.
Caveats before you build this
Uploads have to come from your server or a worker: Infrai doesn’t support setting bucket CORS rules, so a student’s browser can’t PUT into the bucket directly. For 5 MB PDFs, proxying through your route handler is fine — for 500 MB video submissions it wouldn’t be, and R2 or S3 with CORS configured would be the better pick.
Clock skew is real but small. Both your server and the storage host work in UTC, and a signature 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.
And treat a presigned URL as a timer, not a lock. It reveals the object’s storage path, so derive keys from an opaque student id rather than a name, keep expiry tight, and keep the “is this student enrolled in this course” check in the route that mints the slot. The signature can’t make that decision for you.