Scopes — the contract

Declare once where a file goes, who may read it, how big it may be and what it replaces. Both sides validate against the same definition.

Scopes — the contract

A scope is a named destination: the single source of truth for where a file lands, who may read it, and what is accepted there. The registry is a plain object the client and the server both import, which is what makes the two validations agree by construction.

Defining scopes

import { defineScopes, MB } from 'uploaderkit'

export const scopes = defineScopes({
	'user-avatar': {
		path: userId => `Users/${userId}/avatar`,
		visibility: 'public',
		accept: ['png', 'jpg', 'jpeg', 'webp'],
		maxBytes: 5 * MB,
		category: 'image',
		compress: { maxWidth: 512, quality: 0.8, stripExif: true },
	},
})
FieldMeaning
path(entityId, file) => string — the storage key. You own collisions and folder shape.
visibility'public' (direct URL) or 'private' (signed expiring URL only).
acceptExtensions allowed here. Narrower than the category preset, never wider.
maxBytesHard ceiling. KB / MB / GB helpers are exported.
category'image' | 'pdf' | 'document' | 'data' | 'video' | 'audio' | 'certificate' | 'key' | 'any'. Picks the preset that decides whether magic numbers are read.
encryptHand the bytes to the app's cipher before they leave the server.
compressClient-side image pipeline: maxWidth, maxHeight, quality, stripExif (default true).
maxFilesHow many files one entity may hold here. Default 1. The uploader derives multiple from it.
replaceWhat an upload removes. Derived by default — see Replace.
prefix(entityId) => string — objects an 'entity' replace may delete. Defaults to the folder of the resolved key.
metadataFree-form tags forwarded to the provider when it supports them.

Replace — never leave a dead file

Object storage does not clean up after itself. A scope whose key carries the file name writes a NEW object every time, so re-uploading a logo leaves the previous one paying rent forever. replace is what decides that, and its default is derived so there is no prop to forget:

The scopeDerived replaceWhy
maxFiles: 1 (default), key carries file.name'entity'Every upload lands on a new key — the old object must be swept.
maxFiles: 1, key ignores file.name'key'The key is stable, so the provider overwrites in place already.
maxFiles > 1'key'A collection: siblings are the point.

Declare it explicitly only to opt out — replace: false keeps every version.

The 'entity' sweep runs after a successful put and deletes everything under the entity's prefix that is not the new key. Two guards keep it from reaching too far, both at defineScopes time:

  • replace: 'entity' together with maxFiles > 1 throws. A scope cannot hold a collection and erase it on every upload.
  • Two scopes whose folders overlap throw when either sweeps, so an avatar upload can never delete the same user's documents. Give each its own folder, or narrow one with prefix.
defineScopes({
	// One file, swept: uploading `new.png` deletes `old.png`.
	logo: { path: (id, file) => `Companies/${id}/logo/${file.name}` /* … */ },

	// A collection: `maxFiles` alone switches the semantics.
	expediente: {
		path: (id, file) => `Companies/${id}/docs/${file.name}`,
		maxFiles: 10 /* … */,
	},

	// Keeps every version on purpose.
	audit: {
		path: (id, file) => `Companies/${id}/audit/${file.name}`,
		replace: false /* … */,
	},
})

defineScopes returns a ScopeRegistry: names, get(name), has(name) and accept(name) — the last one being the ready-made string for <input accept>.

On this page