@khalidadil/filebot-server (0.2.0)
Installation
@khalidadil:registry=npm install @khalidadil/filebot-server@0.2.0"@khalidadil/filebot-server": "0.2.0"About this package
@khalidadil/filebot-server
Server-side Filebot SDK. It holds the project API key, the signing roots and the webhook secrets, signs media URLs, list tokens and upload tokens locally (no round trip to render an <img>), issues presigned uploads, and mounts the browser-facing host routes through a UploadThing-style file router. Server-only secrets never cross into the browser.
Publishes to the private Gitea registry https://git.kadil.dev/api/packages/khalidadil/npm/ (never GitHub). The registry serves metadata and tarballs anonymously, so consuming apps install with no token.
pnpm add @khalidadil/filebot-server
Node >=20.19, ESM only. Peers (all optional, install the one you mount): express >=4.18, fastify >=5, next >=14, @sveltejs/kit >=2.
Create the client
import { createFilebot } from "@khalidadil/filebot-server";
const fb = createFilebot({
endpoint: "http://api:8080", // API origin reachable from THIS server
publicEndpoint: "https://api.example.com", // API origin the browser uses (optional)
apiKey: process.env.FILEBOT_API_KEY!, // fbk_<32 hex>_<43 base64url>
projectId: process.env.FILEBOT_PROJECT_ID!,
keys: [{ kid: "k1", secret: process.env.FILEBOT_SIGNING_ROOT! }], // for local URL/token signing
webhookSecrets: [process.env.FILEBOT_WEBHOOK_SECRET!], // current first (rotation: current, previous)
});
await fb.loadServing(); // once at startup: caches serveBase, bucket presets, TTL caps
createFilebotFromEnv() reads FILEBOT_ENDPOINT, FILEBOT_PUBLIC_ENDPOINT, FILEBOT_API_KEY, FILEBOT_PROJECT_ID, FILEBOT_SERVE_BASE, FILEBOT_WEBHOOK_SECRETS (comma list) and FILEBOT_SIGNING_KEYS (kid:secret[:notBeforeUnix],…). Invalid options throw FilebotConfigError("invalid_options", field) and never echo the value.
Signed URLs, TTLs and tiers
Signing is local — the SDK holds the per-project root and kid; the edge only verifies.
const url = fb.files.imageUrl(fileRef).preset("card").format("auto").sign({ ttlSec: 3600 });
const orig = fb.files.url(fileRef, { ttlSec: 900 });
const list = fb.files.listToken({ buckets: ["avatars"], identifiers: ["album:42"], ttl: 900 });
TTLs are capped per bucket by the loaded serving response (maxTtlSec, maxHlsTtlSec); the SDK refuses to mint a URL the edge would reject. Only preset names the bucket actually has are accepted.
Two request tiers reach the API:
- API-key tier — anything called through
fb.api.*orfb.uploads.put. Full trust: it may set per-uploadpreserveQuality, may turn a bucket'sscan.modeto"on", and reads/writes any project resource. - Token tier — the browser, via a host-minted upload token (router / QR). Constrained: it uploads only into the route's bucket within the token's limits and cannot choose
preserveQuality(see below).
Uploading from the server
const res = await fb.uploads.put(fileBytesOrStream, {
bucket: "reports",
filename: "q3.pdf",
contentType: "application/pdf",
preserveQuality: true, // API-key tier only
});
Streamed puts use a bounded part pool (concurrency × part size); pass concurrency to trade memory for throughput.
preserveQuality is API-key-only (OI-8, AM-3)
Per-upload preserveQuality (keep the raw original) is accepted only on the API-key tier. A token-tier upload (router or QR) that tries to set it is rejected 400 forbidden_field; the bucket's preserve_quality_default applies instead. This is decision 5: raw-original is a per-bucket choice, and only trusted server code may override it per file. The default itself is false (BUCKET_DEFAULTS.preserveQuality).
Host routes (the file router)
Mount one route handler and the browser client talks only to your app. The router exposes POST {mount}/upload-token, /qr, /list-token, /events-ticket and /webhook under a mount (default /api/filebot). Every host route answers JSON.
import { createFileRouter, route } from "@khalidadil/filebot-server";
import { filebotExpress } from "@khalidadil/filebot-server/express"; // framework adapters live on subpaths
import express from "express";
// createFileRouter is curried: call it with no args, then pass the plain routes object.
const router = createFileRouter<Req>()({
avatars: route({ bucket: "avatars", contentTypes: ["image/*"], maxFileBytes: "8MB", maxFiles: 1 })
// A route WITHOUT middleware answers 403 — a route is never public (REFINE-3).
.middleware(async ({ req, files }) => {
const userId = await auth(req); // your app owns auth
return { identifiers: [`user:${userId}`], metadata: { userId } };
})
.onComplete(async ({ file, metadata }) => { // fired off a verified file.ready webhook
await db.saveAvatar(metadata.userId, file.ref);
})
.onError(async ({ status, reason, metadata }) => {
await db.markFailed(metadata?.userId, status, reason); // "failed" | "quarantined"
}),
});
const app = express();
// Mount BEFORE any body parser: express.json() would consume the raw webhook body (R-3).
app.use(filebotExpress(fb, router, {
events: { authorize }, // see below — default-deny
lists: { authorize: listAuth },
}));
Adapters: filebotExpress, filebotFastify, filebotNext (export const { GET, POST } = filebotNext(fb, router)), filebotSvelteKit, and the framework-neutral createFetchHandler they build on. A middleware may tighten a route's limits (narrow) but widening throws FilebotConfigError → 500. Host routes require a JSON content type and reject Sec-Fetch-Site: cross-site unless allowCrossSite (REFINE-2).
operator_required: an API key cannot disarm scanning (AM-1)
Security scanning is per bucket (scan.mode: "on" | "off", default "on"; decision 8). Turning it off is an operator action: POST /v1/buckets or PATCH /v1/buckets/:id with an API-key principal that lowers the effective scan.mode to "off" returns 403 operator_required. An API key may set "on", and a PATCH that leaves an already-off bucket off passes. So fb.api.buckets.create({ processingPolicy: { scan: { mode: "off" } } }) fails with operator_required — turn scanning off in the console instead.
Events tickets are default-deny (OI-6, security-14)
POST {mount}/events-ticket mints a live-events subscription ticket. It is default-deny for every scope: with no events option the route answers 403, and a ticket is minted only when events.authorize(...) returns exactly true. Passing an upload route's middleware is not ownership of a file — the router never consults route middleware for events. You own the ownership check:
const authorize = async ({ kind, id, req }) => {
const userId = await auth(req);
if (kind === "file") return await db.fileBelongsTo(id, userId); // this user owns this file
if (kind === "ident") return id === `user:${userId}`; // this user's own identifier scope
return false; // everything else denied
};
The browser does not need this route to watch its own upload: the token-tier init response already carries a sess.<sessionId> events ticket (FR-4), and QR desktops get theirs from /qr. /events-ticket is for a host that wants to grant a broader scope after its own ownership check.
Webhooks → onComplete
The router's /webhook route verifies the HMAC (raw body, webhookSecrets, current-first for rotation, ±300 s tolerance) and dispatches file.ready / file.failed / file.quarantined to the owning route's onComplete / onError. The file's stored meta (route, your metadata.data) rides the webhook envelope, so onComplete needs no extra API read (FR-3). A route-agnostic webhook.onEvent(e) runs for every verified event (webhook.ping, file.video.*, transfer.*).
Standalone (outside the router) verify with:
import { verifyWebhook } from "@khalidadil/filebot-server";
const event = verifyWebhook(rawBodyBytes, req.headers, { secrets: fb.webhooks }); // throws FilebotWebhookError on mismatch
Errors
FilebotApiError (typed /v1 failures: code, detail, retryable, requestId), FilebotConfigError, FilebotUploadError, FilebotWebhookError. Error messages and detail never contain the API key, a Bearer token, a fuk_ upload key or an X-Amz-Signature.
Testing
pnpm --filter @khalidadil/filebot-server test runs the unit suite (no services). test/pack.test.ts asserts the published tarball ships dist/** + README.md + package.json only, with the exact runtime deps. Integration and end-to-end (including the journey-1 HEIC gate) live in examples/host-app; see the vault doc Features/SDKs.md.
Release
The five packages (signing, contracts, server, client, react) publish as one lockstep set at one version. pnpm exec tsx packages/filebot-server/scripts/lockstep-check.ts verifies that and prints, but never runs, the publish command — publishing is a human gate that needs the lead's go and Khalid's approval.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @khalidadil/filebot-contracts | 0.2.0 |
| @khalidadil/filebot-signing | 0.2.0 |
Development Dependencies
| ID | Version |
|---|---|
| @types/express | ^5 |
| @types/node | ^22.10.0 |
| express | ^5 |
| fastify | ^5.2.0 |
| tsx | ^4.19.2 |
| typescript | ^5.7.0 |
Peer Dependencies
| ID | Version |
|---|---|
| @sveltejs/kit | >=2 |
| express | >=4.18 |
| fastify | >=5 |
| next | >=14 |