khalidadil
  • Joined on 2025-08-25

@khalidadil/filebot-server (0.1.0)

Published 2026-09-18 15:42:30 +00:00 by khalidadil

Installation

@khalidadil:registry=
npm install @khalidadil/filebot-server@0.1.0
"@khalidadil/filebot-server": "0.1.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.* or fb.uploads.put. Full trust: it may set per-upload preserveQuality, may turn a bucket's scan.mode to "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, filebotExpress } from "@khalidadil/filebot-server";
import express from "express";

const router = createFileRouter((f) => ({
  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.1.0
@khalidadil/filebot-signing 0.1.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
Details
npm
2026-09-18 15:42:30 +00:00
0
97 KiB
Assets (1)
Versions (2) View all
0.2.0 2026-09-26
0.1.0 2026-09-18