Error Reference
Every error condition the static asset controller and storage helpers can raise, with cause and fix.
Catalogued errors
These carry a machine code a client can branch on, alongside the human message. The code is the stable contract; the message text may be overridden at a throw site to name the offending value.
| Code | Message text | HTTP Status | Category |
|---|---|---|---|
core.static_asset.bucket_name_invalid | "Invalid bucket name" | 400 | VALIDATION |
core.static_asset.object_name_invalid | "Invalid object name or path" | 400 | VALIDATION |
core.static_asset.folder_path_invalid | "Invalid folder path" | 400 | VALIDATION |
core.static_asset.folder_segment_invalid | "Invalid folder path segment" | 400 | VALIDATION |
core.static_asset.folder_depth_exceeded | "Folder path exceeds the maximum depth" | 400 | VALIDATION |
core.static_asset.file_empty | "Empty file content" | 400 | VALIDATION |
core.static_asset.max_keys_invalid | "Invalid maxKeys - expected a positive integer" | 400 | VALIDATION |
core.static_asset.upload_too_large | "File is larger than the maximum allowed" | 413 | VALIDATION |
core.static_asset.upload_not_authorized | "Not allowed to request an upload policy" | 403 | VALIDATION |
core.static_asset.invalid_commit_token | "Invalid commit token" | 400 | VALIDATION |
core.static_asset.expired_commit_token | "Commit token has expired" | 400 | VALIDATION |
core.static_asset.labels_in_query | "Upload labels belong in the request body, not the query" | 400 | VALIDATION |
core.static_asset.object_key_out_of_scope | "Object key is outside the key prefix of this controller" | 400 | VALIDATION |
core.storage.object_not_found | "Object not found" | 404 | BUSINESS |
The eight core.static_asset.* codes live on StaticAssetErrors; core.storage.object_not_found lives on StorageErrors in the helpers package. Both register with the shared key registry, so messageCode autocompletes.
NOTE
core.request.body_too_large (413) and core.request.body_malformed (400) are not static-asset codes - they live on the kernel's RequestErrors, alongside every other route. See Middlewares Reference.
Error conditions
| Message | Cause | Code | HTTP Status |
|---|---|---|---|
"Invalid bucket name" | bucketName fails isValidBucketName() | core.static_asset.bucket_name_invalid | 400 |
"Invalid object name or path" | objectName fails isValidObjectKey() | core.static_asset.object_name_invalid | 400 |
"Invalid folder path" | Upload's folderPath form field is empty after trimming leading/trailing slashes | core.static_asset.folder_path_invalid | 400 |
"Folder path exceeds max depth of {n}" | Upload's folderPath has more segments than maxFolderDepth (default 2) | core.static_asset.folder_depth_exceeded | 400 |
"Invalid folder path segment: {segment}" | One folderPath segment fails isValidSegment() | core.static_asset.folder_segment_invalid | 400 |
"Empty file content | name: {originalName}" | The uploaded file's buffer is empty after multipart parsing (or after re-reading a disk-spooled file) | core.static_asset.file_empty | 400 |
"Invalid maxKeys | Expected a positive integer | value: {value}" | listObjects's maxKeys query param does not parse to a positive integer | core.static_asset.max_keys_invalid | 400 |
[getObject] Object not found | bucket: {bucket} | key: {key} (also [getObjectStream], [getStat], [removeObject]) | No object at that bucket and key. DiskHelper throws it directly; BunS3Helper and MinioHelper map the backend's own failure onto it through asStorageError | core.storage.object_not_found | 404 |
Object not found | key: {key} | The key does not start with controller.keyPrefix - answered before any storage call, so a caller learns nothing about a key outside its scope | core.storage.object_not_found | 404 |
"Object key is outside the key prefix of this controller" | A resolveObjectName or extra.normalizeNameFn key falls outside controller.keyPrefix | core.static_asset.object_key_out_of_scope | 400 |
[upload] Bucket does not exist | name: {bucket} | helper.upload() found no matching bucket via hasBucket() | - | 400 (default) |
[upload] Invalid original file name | With no naming hook, a file's originalName becomes the key and fails isValidSegment() (single segment: no /). The static-asset controller with resolveObjectName or extra.normalizeNameFn set relaxes this - originalName is then metadata, checked by the looser isValidOriginalName() (not blank, at most 255 characters, no control character or lone surrogate). One exception: controller.keyPrefix set with neither hook (the component's own default naming under a scope) keeps the strict, single-segment rule - a keyPrefix alone does not relax it | - | 400 (default) |
[upload] Invalid folder path | depth: {n} | max: {m} | helper.upload()'s own check found more folderPath segments than maxFolderDepth allows | - | 400 (default) |
[upload] Invalid folder path | helper.upload()'s own path check failed for any other reason | - | 400 (default) |
[upload] Invalid file size | size: {size} | A file's size is undefined, null, or negative | - | 400 (default) |
[upload] Invalid normalized object name | name: {name} | The key returned by normalizeNameFn fails isValidObjectKey() | - | 400 (default) |
[createBucket] Invalid name to create bucket! | createBucket() called with a name failing isValidBucketName() - only reachable calling a helper directly, the controller validates first | - | 400 (default) |
[createBucket] Bucket already exists | name: {name} | DiskHelper.createBucket() called with a name that already exists on disk. BunS3Helper throws whatever the S3 endpoint returns for the same case instead | - | 400 (default) |
[removeBucket] Invalid name to remove bucket! | removeBucket() called with a name failing isValidBucketName() - only reachable calling a helper directly | - | 400 (default) |
[removeBucket] Bucket does not exist | name: {name} | DiskHelper.removeBucket() - no directory at that bucket name | - | 400 (default) |
[removeBucket] Bucket is not empty | name: {name} | DiskHelper.removeBucket() - the bucket directory still has files in it | - | 400 (default) |
[parseMultipartBody] storage: {storage} | Invalid storage type | Valids: ['memory', 'disk'] | extra.parseMultipartBody.storage set to something other than 'memory'/'disk' - a configuration error, not user input | - | 400 (default) |
NOTE
Entries marked "default" pass no explicit statusCode to getError(). ApplicationError defaults statusCode to 400 in that case.
Name validation rules
Bucket names are validated with isValidBucketName(), which is isValidSegment() applied to bucket.name (single segment, no path separators). Object keys, which may include folder segments (for example 2026/uploads/report.pdf), are validated with isValidObjectKey() - every segment still runs through isValidSegment().
| Pattern | Example | Reason |
|---|---|---|
| Path traversal | ../etc/passwd | Contains .., /, or \ |
| Hidden files | .hidden | Starts with . |
| Shell metacharacters | file;rm -rf / | Contains ;, |, &, $, `, <, >, {, }, [, ], !, or # |
| Control characters | file\ninjected | Contains \n, \r, or \0 |
| Long names | 256+ characters | Exceeds the 255-character limit |
| Empty names | "", " " | Empty or whitespace-only |
| Double slashes (key only) | a//b.png | isValidObjectKey() rejects empty segments |
| Excess folder depth (key only) | a/b/c/d.png with maxFolderDepth: 2 | folderDepth = segments.length - 1 exceeds the limit |
| Long keys (key only) | 1025+ characters | Exceeds the 1024-character normalized-key limit |
Troubleshooting
"Invalid bucket name" / "Invalid object name or path"
- Cause: the name fails the rules above.
- Fix: strip path separators, leading dots, shell metacharacters, and control characters. Keep names under 255 characters (1024 for a full object path). Never send an empty or whitespace-only value.
// Wrong - contains a path separator, rejected by isValidBucketName()
await fetch('/assets/buckets/user/uploads', { method: 'POST' });
// Right - one segment
await fetch('/assets/buckets/user-uploads', { method: 'POST' });"Folder path exceeds max depth" / "Invalid folder path segment"
- Cause: the upload's
folderPathform field has more segments thanmaxFolderDepthallows (default2), or one segment failsisValidSegment(). - Fix: flatten the folder structure, or raise the limit via
extra.maxFolderDepthwhen registering the backend.
{
storage: StaticAssetStorageTypes.DISK,
helper: new DiskHelper({ basePath: './uploads' }),
extra: { maxFolderDepth: 4 },
}"Empty file content"
- Cause: the uploaded file's buffer is empty - either the client sent a zero-byte file field, or
parseMultipartBodyproduced no readable content. - Fix: verify the
FormDatafield actually carries file bytes before submitting. A zero-byte file is rejected - this is not a size-limit issue.
"Invalid maxKeys"
- Cause:
listObjects'smaxKeysquery string does not parse to a positive integer viaNumber(maxKeys)+Number.isInteger()- for examplemaxKeys=abcormaxKeys=-1. - Fix: only send positive integer strings, or omit the parameter entirely.
url.searchParams.set('maxKeys', '50'); // OK
// url.searchParams.set('maxKeys', 'all'); // 400A key that should exist answers "Object not found" (404)
- Cause: the controller has
controller.keyPrefixset, and the requested key does not start with it. The route answers exactly as it would for a real miss, before calling storage, so a caller learns nothing about a key it does not own. - Fix: request the key with its full prefix, for example
tenant-a/photos/2026/f.jpgon a controller scoped totenant-a.
"Object key is outside the key prefix of this controller"
- Cause:
controller.keyPrefixis set, andresolveObjectNameorextra.normalizeNameFnreturned a key outside it. The component never rewrites the key into scope - the application may have recorded the out-of-scope key elsewhere. - Fix: have the hook build keys under the prefix, or drop
controller.keyPrefixif the hook already scopes keys on its own.
"[upload] Invalid original file name" under controller.keyPrefix
- Cause:
controller.keyPrefixis set with neitherresolveObjectNamenorextra.normalizeNameFn- the component's own default naming under a scope. That path keeps the strict, single-segment rule a name has without any scope;keyPrefixalone does not relax it. - Fix: rename the file (drop the
/), or setresolveObjectName/extra.normalizeNameFnto build the key yourself, which frees the original name to be metadata.
"keyPrefix needs controller.bucket"
- Cause:
controller.keyPrefixis set withoutcontroller.bucket(bucket-in-URL mode). With no configured bucket, the four bucket-management routes cannot be scoped, sokeyPrefixwould isolate only part of the controller's surface - the controller refuses to register rather than isolate less than its name promises. - Fix: add
controller.bucket, the same requirementdirectUploadalready has.
"[upload] Bucket does not exist"
- Cause:
helper.upload()checkedhasBucket()and found no match - the bucket was never created, or was deleted between requests. - Fix: create the bucket first with
POST /buckets/:bucketName, or checkGET /buckets/:bucketNamebefore uploading.
"Bucket already exists"
- Cause:
POST /buckets/:bucketNamewas called for a bucket that already exists.createBucket()throws rather than returningnull- it does not silently succeed on a duplicate. - Fix: check
GET /buckets/:bucketNamefirst, or treat the throw as "already there" and continue.
Controller not registering / no routes appear
- Cause:
STATIC_ASSET_COMPONENT_OPTIONSwas never bound, or was bound to{}- the component's default.binding()iterates the options object and produces zero controllers for an empty map, without throwing. - Fix: bind a non-empty options object before
this.component(StaticAssetComponent):
this.bind<TStaticAssetsComponentOptions>({
key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
}).toValue({
[uniqueKey]: {
controller: { name: 'AssetController', basePath: '/assets' },
storage: StaticAssetStorageTypes.DISK,
helper: new DiskHelper({ basePath: './uploads' }),
},
});
this.component(StaticAssetComponent);Files not uploading (DiskHelper)
- Cause: the process lacks filesystem permissions on
basePath, or the disk is out of space.DiskHelper's constructor already createsbasePathif it does not exist, so a missing directory is rarely the issue. - Fix: verify the process has read/write permissions to the target path and enough free space.
Files not uploading (BunS3Helper)
- Cause: endpoint connectivity or authentication failure.
- Fix:
- Confirm the endpoint is reachable from this process.
- Verify
accessKey/secretKeyand thatregionmatches the bucket. - Behind a private endpoint, set
endpoint.publicso signed URLs carry a host a browser can reach.
Large file uploads failing or timing out
- Cause:
parseMultipartBody: { storage: 'memory' }(the default) buffers the entire file in process memory before writing it to the backend. - Fix: switch to disk-based spooling:
extra: {
parseMultipartBody: { storage: 'disk', uploadDir: './tmp/uploads' },
}BunS3Helper fails to construct or import
- Cause: running on Node.js.
BunS3HelperimportsS3Clientfrom thebunbuiltin module, which only resolves under the Bun runtime. - Fix: run the app under Bun, or use
DiskHelperoutside it.
See also
- Overview - quick start, imports, and common configuration tasks
- Usage & Examples - task-oriented walkthroughs for every endpoint and MetaLink setup
- Full Reference - controller factory,
IStorageHelperinterface, MetaLink schema, internals
The size ceiling, and the two token errors
upload_too_large is one code for both upload paths: over extra.maxBytes on POST {base}/objects, and over directUpload.maxBytes when a policy is requested. A second code would have been the same condition wearing two names.
The ordinary route checks twice. The request's content-length is read before the body is spooled, so an oversized upload is refused without first landing on disk; then each parsed file is checked against its actual byte length, which is the authoritative one - a multipart envelope is larger than the files inside it, and a declared length is whatever the client declared.
invalid_commit_token and expired_commit_token are both 400 and neither is a 404. A caller learns only that the token is no good, never whether the key it names exists - the route is not a name prober, and a forged token fails before any storage call, so timing leaks nothing either.