Accidental-deletion defenses for a production object store

No versioning on your bucket? Layer a force-delete guard, copy-to-trash soft deletes, lifecycle expiry and a usage tripwire — with the calls that build each one.

Start from an honest inventory of what your store gives you. Infrai’s storage surface has no object versioning and no immutability lock, so there’s no checkbox that makes yesterday’s bytes recoverable. What you can build is a stack of cheap layers: a force flag that stops a bare DELETE from emptying a bucket, a copy-to-trash step in front of every destructive call, a lifecycle rule that empties the trash on a clock, and a usage tripwire that notices when the object count drops.

Most of these incidents aren’t malicious. They’re a cleanup script whose prefix variable came back empty, or a migration that ran against the wrong bucket name because staging and production differ by four characters. Infrai’s API can’t stop you writing that script — but it can make the blast radius small and the recovery boring.

The guard that ships with the bucket

Deleting a bucket that still holds objects fails by default:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X DELETE "https://api.infrai.cc/v1/storage/bucket/delete/kb-guard-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": false,
  "error": {
    "code": "STORAGE_DELETE_NOT_FORCED",
    "http_status": 400,
    "message": "bucket 'bkt_2c6a0b5cff5f4a31a3b289' not empty; pass force=True to delete contents",
    "retryable": false
  }
}

That’s a real guard and it’s saved people, but read it for what it is: one typed ?force=true away from gone, with no undo. Treat the flag as a speed bump, not a control — and never let it appear in a script that also computes the bucket name at runtime.

Blast radius is the cheaper lever anyway. Buckets are free to create, so give production, staging and each throwaway experiment their own. A script that can only see kb-guard-0726 cannot wipe the invoices bucket, whatever it was told to do.

Soft delete, in two calls

Object stores don’t have a recycle bin, so build one. Copy the object under a dated trash/ prefix, then delete the original — server-side copy means no bytes move through your machine:

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-guard-0726",
    "src_key": "live/tenant-42/settings.json",
    "dst_bucket": "kb-guard-0726",
    "dst_key": "trash/2026-07-26/live/tenant-42/settings.json"
  }'

Content type and custom metadata survive the copy, which is what makes a restore a single call back the other way rather than a reconstruction. Then let a lifecycle rule take out the rubbish, so nobody has to remember:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-guard-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules": [{"prefix": "trash/", "expire_days": 14}]}'

Fourteen days of regret capacity, priced at fourteen days of storage for the deleted set. The rules array replaces the bucket’s whole policy rather than merging into it, so keep every rule in one place — a partial submission silently drops the rules you left out.

A delete script that refuses to run away

The wrapper is where the real defense lives. Three properties matter: it lists what it intends to delete before it deletes anything, it stops if the list is bigger than a human expected, and it copies before it destroys.

import process from "node:process";

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-guard-0726";
const PREFIX = process.argv[2];
const CEILING = Number(process.env.DELETE_CEILING ?? 50);
const APPLY = process.argv.includes("--apply");

if (!PREFIX || PREFIX.length < 5) throw new Error("refusing to run on an empty or very short prefix");

const auth = { Authorization: `Bearer ${KEY}` };
const listUrl = `${API}/v1/storage/object/list/${BUCKET}?prefix=${encodeURIComponent(PREFIX)}&limit=1000`;
const listed = await fetch(listUrl, { method: "GET", headers: auth });
if (!listed.ok) throw new Error(`list failed: ${listed.status} ${await listed.text()}`);

const items = (await listed.json()).data.items ?? [];
console.log(`${items.length} object(s) under ${PREFIX}`);
if (items.length > CEILING) throw new Error(`ceiling ${CEILING} exceeded — widen it deliberately or narrow the prefix`);
if (!APPLY) { console.log(items.map((o) => o.key).join("\n")); process.exit(0); }

const stamp = new Date().toISOString().slice(0, 10);
for (const o of items) {
  const copy = await fetch(`${API}/v1/storage/object/copy`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ src_bucket: BUCKET, src_key: o.key, dst_bucket: BUCKET, dst_key: `trash/${stamp}/${o.key}` }),
  });
  if (!copy.ok) throw new Error(`trash copy failed for ${o.key}: ${copy.status}`);
}

const gone = await fetch(`${API}/v1/storage/object/delete_batch/${BUCKET}`, {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json" },
  body: JSON.stringify({ keys: items.map((o) => o.key) }),
});
const result = (await gone.json()).data;
console.log(`deleted ${result.deleted.length}, failed ${result.errors.length}`);

Run it without --apply and it prints. Run it with --apply and every object is in the trash prefix before a single one disappears. POST /v1/storage/object/delete_batch/{bucket} takes up to 1,000 keys and reports per-key outcomes rather than aborting, so a key that’s already gone comes back in errors with STORAGE_OBJECT_NOT_FOUND and the rest still succeed — which makes the whole thing safe to re-run after a network wobble.

Tripwires

Deletion incidents are usually discovered by a customer. They shouldn’t be. Bucket usage is free to read and gives you a number to alarm on:

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-guard-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "byte_count": 34,
    "object_count": 3,
    "as_of": "2026-07-26T00:43:55.441473Z"
  }
}

Snapshot that hourly and alert on any drop greater than, say, 2% between two samples. For something closer to real time, subscribe the bucket to deletion events and let it page you:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_notification/kb-guard-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"events": ["object.deleted"], "target": {"url": "https://api.example.com/hooks/storage"}}'

The response is a subscription_id. Worth flagging: there’s no route to list or read back subscriptions, so record that id yourself or you’ll be guessing later what a bucket is wired to.

How the layers compare

DefenseStopsCostsFails against
Separate bucket per environmentwrong-target scriptsnothing — buckets are freea script pointed at the right bucket
force=true requirementa bare bucket deletenothinganyone who reads the error and adds the flag
Copy-to-trash wrapperroutine object deletesone copy call per objectdeletes that bypass your wrapper
trash/ lifecycle ruletrash growing foreverstorage for the windownothing — it’s cleanup, not protection
Usage tripwiresilent, slow data lossnothinga delete-and-restore inside one sample window
Object versioningeverything abovenot available here

What this doesn’t defend against

A compromised API key. Every layer above is voluntary — the wrapper only protects the paths that go through it, and a key with storage access can call DELETE /v1/storage/object/delete/{bucket}/{key} directly, all day. If your requirement is that deletion is impossible for a defined window even with valid credentials, that’s a different product feature: S3 Object Lock in compliance mode holds an object against the root account itself, and Google Cloud Storage’s bucket-level soft delete restores objects with no wrapper of yours involved. Neither has an equivalent here, and no amount of scripting substitutes for one — MinIO, if you already run it on your own disks, supports versioning and object lock too.

What you get in exchange is that the trash copy, the lifecycle rule, the tripwire alarm, the cron entry that samples usage and the error record when the sampler fails all sit on one credential and one bill.

What the guardrails cost

Structurally: listing, heads, lifecycle rules, notifications, usage and deletes are free and rate-limited. The only metered call in the whole pattern is the trash copy — verified 26 July 2026 at $0.0001 per copy, so soft-deleting 10,000 objects costs about $1 in call charges plus a fortnight of their storage. Rates drift downward and discount campaigns run, so read today’s number instead of trusting this paragraph:

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', 0)) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

New accounts get $2 of trial credit, which covers a lot of experimenting with a wrapper before it goes anywhere near production data.

References

Browse more storage developer guides