audit
Write a structured who/what/when record of every mutation to an awaited sink - the durable, awaitable counterpart to the fire-and-forget onAction hook. One record per operation carrying the verb, caller-facing key, actor, time, duration, and outcome. Body-transparent, no metadata, no native dependencies.
The built-in audit() plugin writes a structured who / what / when record of every mutation to a sink you provide. Unlike the fire-and-forget onAction hook, the sink is awaited - the operation doesn't resolve until the record is written, so you get ordering, back-pressure, and a write failure you can actually see.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { audit } from "files-sdk/audit";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [
audit({
actor: () => currentUser()?.id, // read from your request context
sink: (record) => db.insert("audit_log", record), // awaited
}),
],
});
await files.delete("notes.txt");
// → sink({ action: "delete", key: "notes.txt", actor: "u_42",
// at: 1717…, durationMs: 12, status: "success" })The record
Each audited operation produces one AuditRecord:
| Field | Always? | What it is |
|---|---|---|
action | yes | The verb (upload, delete, copy, move, signedUploadUrl, …). |
key | — | Caller-facing key, for every verb except copy / move / list. |
from, to | — | Source / destination, for copy and move. |
actor | — | Who performed it, from the actor resolver. |
at | yes | When the operation started (ms since epoch). |
durationMs | yes | Wall-clock duration of the logical operation. |
status | yes | "success" or "error". |
size | — | Stored byte size, on a successful upload. |
bulk | — | true when the record is one item of a bulk ([...]) call. |
error | — | { code, message }, on status: "error". |
Keys are always the caller-facing ones, never the internal prefixed path.
Awaited, not fire-and-forget
A hook is called but never awaited; a hook that's slow or throws can't affect the operation. audit() is the opposite by design:
- The operation waits for the sink.
await files.delete(...)doesn't resolve until your sink resolves, so records land in order and a slow sink applies back-pressure. - On success, a rejecting sink fails the call. The mutation already happened but wasn't recorded - rather than silently drop the entry, the call rejects so you decide what to do (retry, alert). Fail closed.
- On failure, the operation's error always wins. When the operation itself throws, the record is written best-effort; a sink that also rejects while recording the failure is suppressed so it can never mask why the call failed.
If you'd rather audit best-effort, catch inside your own sink - then it never rejects and never fails a call.
Options
| Option | Default | What it does |
|---|---|---|
sink | (required) | (record) => void | Promise<void>, awaited. Where each record is written. |
actor | — | (op) => string | undefined. Resolve who - typically read synchronously from an AsyncLocalStorage. |
events | "writes" | Which verbs to record: "writes", "all" (reads included), or an explicit list like ["upload", "delete"]. |
clock | Date.now | The clock used for at and durationMs. Inject a fake for deterministic tests or a trusted time source. |
Which operations are recorded
By default audit() records the mutating verbs - upload, delete, copy, move, and signedUploadUrl (minting an upload capability is a write worth logging). Pass events: "all" to also record reads (download, head, exists, list, url), or an explicit list to record exactly the verbs you name:
audit({ sink, events: ["upload", "delete"] }); // only these twoAttributing the actor
The actor resolver receives the full operation, so you can read it from request context or derive it from the key:
audit({
sink,
actor: (op) => {
const user = requestContext.get()?.user; // e.g. an AsyncLocalStorage
return user?.id;
},
});Return undefined to leave actor off a record; omit the option to never set one.
Ordering
Put audit() first (outermost) so it records the caller's logical intent. A delete that an inner softDelete() turns into a move is still audited as the delete the caller asked for; a body an inner encryption() seals is still recorded at its logical size.
plugins: [audit({ sink }), softDelete(), encryption(key)];Placed last (innermost) it instead records the physical operations the pipeline above expands into - the move soft-delete actually issued, the encrypted byte size. Both are valid; pick the layer whose history you want.
Things to keep in mind
- One record per logical operation. Plugins run outside retries, so a call that retries three times is still one record - its
durationMsspans the retries. - Bulk fans out to one record per item.
upload([...])/delete([...])record each key individually, flaggedbulk: true, with per-item success/error - exactly the granularity an audit log wants. - Body-transparent, works on any adapter. It never buffers, transforms, or reads the body (
sizecomes from the upload result's declared metadata, not the bytes), so streaming, range downloads,url(), andsignedUploadUrl()all keep working. It writes no object metadata and has no native dependencies. - Not a security boundary. It records operations made through the instance; a direct presigned
PUTto the bucket bypasses it. Pair it withsignedUrlPolicy()to keep the URLs you mint tight. wrap-only. It adds no methods, so plainnew Files({ plugins })works - thoughcreateFilesis fine too and keeps you consistent with the extend-based plugins.
API
The plugin contract types and helpers - FilesPlugin, FilesOperation, handlers, and createFiles. See the overview for how they compose.
cache
An LRU/KV cache in front of head(), url(), and small download()s. Repeat reads of an unchanged key are served from memory; writes through the instance invalidate the affected key. Body-transparent, no native dependencies, works on any adapter.