validation
A fail-closed guard that vets every upload before any bytes move - enforce a max/min size, an allowed-MIME-type list, and a key-naming rule.
The built-in validation() plugin is a fail-closed guard: it checks each write against the rules you set and rejects a bad one by throwing, so no bytes ever reach the adapter. It’s the simplest kind of wrap plugin - it vetoes rather than transforms.
Unlike compression() and encryption(), it never touches the body or writes any metadata, so there’s nothing to undo on the way back out. It has no native dependencies and works on any adapter.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { validation } from "files-sdk/validation";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [
validation({
maxSize: 10 * 1024 * 1024, // 10 MiB
allowedTypes: ["image/*", "application/pdf"],
key: /^[\w.-]+$/,
}),
],
});
await files.upload("photo.png", bytes); // ok
await files.upload("notes.txt", "..."); // throws: type not allowed
What it checks
Every option is independent - set any combination, and with none set the plugin is a no-op pass-through.
| Option | What it does |
|---|---|
maxSize |
Reject uploads larger than this many bytes. |
minSize |
Reject uploads smaller than this many bytes - e.g. 1 to refuse empty files. |
allowedTypes |
Reject uploads whose MIME type isn’t in the list. |
key |
Reject upload (and copy / move destinations) whose key fails the rule. |
Sizes
maxSize and minSize are byte counts. Known-length bodies - strings (measured in UTF-8 bytes), Uint8Array, ArrayBuffer, Blob, and File - are measured without being read or copied and forwarded as-is, so a multi-gigabyte File over maxSize is rejected without being loaded into memory. Only an unknown-length stream has to be buffered to measure it (the same trade-off the buffering plugins make).
Types
Each allowedTypes entry is an exact type ("image/png") or a group wildcard ("image/*"). Matching is case-insensitive and ignores any ; charset=... parameter. The type checked is options.contentType if you pass it, otherwise a Blob/File’s own .type, otherwise the type inferred from the key’s extension - and the plugin forwards that checked type as the upload’s contentType, so it’s the type the object is stored as.
validation({ allowedTypes: ["image/*"] });
await files.upload("photo.png", bytes); // ok - inferred image/png
await files.upload("doc.pdf", bytes); // throws - application/pdf not allowed
Keys
key is either a RegExp the key must match or a predicate that returns true for keys you allow. Anchor your pattern (/^[\w.-]+$/) and don’t use the g flag. The rule also guards the destination of copy and move, so a rename can’t smuggle in a key your uploads would reject.
validation({ key: (key) => key.startsWith(`${tenantId}/`) });
Telling failures apart
Every rejection is a ValidationError - a regular FilesError (code: "Provider") with a reason discriminant of "size", "type", or "key" - so you can branch on which rule failed without parsing the message:
import { ValidationError } from "files-sdk/validation";
try {
await files.upload(key, body);
} catch (error) {
if (error instanceof ValidationError && error.reason === "type") {
return reply(415, "unsupported file type");
}
if (error instanceof ValidationError && error.reason === "size") {
return reply(413, "file too large or too small");
}
throw error;
}
maxSize and minSize share reason: "size" - the message says which bound was crossed. The signedUploadUrl() fail-closed throw (below) is a plain FilesError, not a ValidationError: that’s the plugin refusing an unenforceable operation, not your file failing a rule.
Ordering
Plugins run in array order, plugins[0] outermost. Two neighbours need to sit in a particular place relative to validation():
plugins: [
contentType(),
validation({ maxSize, allowedTypes }),
versioning(),
compression(),
encryption(key),
];
contentType()goes before it.validation()checks the type the client claims. AcontentType()placed after it can still relabel the upload from its bytes - a.pngthat’s really HTML becomestext/html- afterallowedTypeshas already approvedimage/png. WithcontentType()first,validation()checks the corrected type and rejects it.versioning(),softDelete(),dedup(), and body transforms go after it. Those plugins’ own housekeeping writes (.versions/...,.trash/...,.dedup/..., and dedup’s empty pointer bodies) only pass through the plugins after them. Putvalidation()after them and a rule likekey: /^[\w.-]+$/orminSize: 1rejects those internal writes, so every overwrite, delete, or upload fails. In front of them, it vets only the caller’s own keys and bytes.
Things to keep in mind
- Reads and
url()pass straight through;copyandmoveonly have their destination key checked. The plugin only guards writes; it transforms nothing, so there’s nothing to reverse on download. - It stores no metadata. Nothing rides along on the object, so a validated bucket is indistinguishable from an unvalidated one - safe to enable or remove at any time.
- Size rules buffer unknown-length streams. Measuring a stream means draining it, which is incompatible with resumable uploads. Key and type rules never touch the body, so a key/type-only policy stays fully streaming.
- It’s a guard, not a sanitizer. It vets the declared type and the key; it doesn’t sniff magic bytes. Pair it with
contentType(), placed before it, if you don’t trust the client-declared type.