Skip to content

ccs-files-client

@central-code-station/ccs-files-client is the File Service client for backends: a tenant application’s API, a platform service, or any trusted server that stores files for its users. It holds a client secret, so it never runs in a browser or an app. This page covers installation, configuration and the behaviour you rely on; every type and method signature is in the generated reference, the release history in the changelog.

Published privately on GitHub Packages. Map the scope in your project’s .npmrc; authenticate with a GitHub token that has read:packages, kept in your user ~/.npmrc or your CI secrets, never in the repository:

@central-code-station:registry=https://npm.pkg.github.com
Terminal window
npm install @central-code-station/ccs-files-client

No runtime dependencies. Node.js 20 or newer (it uses the global fetch, Blob and Buffer). ES modules and CommonJS, with type declarations for both; load it one way per process, because the two builds are separate copies and instanceof does not cross them.

files-client.ts
import { FilesClient } from '@central-code-station/ccs-files-client';
// One client per process: it fetches its machine token lazily and caches it until shortly before it expires.
// It talks to the production platform; CCS_ENVIRONMENT=local (or staging) in your environment switches it, and
// issuerUrl, apiUrl and resource can be passed explicitly.
export const files = new FilesClient({
clientId: process.env.CCS_FILES_CLIENT_ID!, // your backend client, with files:manage on the Files API
clientSecret: process.env.CCS_FILES_CLIENT_SECRET!, // server-side only
tenantId: process.env.CCS_TENANT_ID, // the default context: your tenant ...
applicationId: process.env.CCS_APPLICATION_ID, // ... and your tenant application
});
Option Required Meaning
issuerUrl no The Identity Server’s bare origin (no /api). Default: production, https://auth.centralcodestation.com
clientId, clientSecret yes Your backend client. The secret stays on the server
apiUrl no The File Service API base URL. Default: FILES_API_URL, else derived from resource, else production, https://files.centralcodestation.com/api/v1
resource no The Files API’s identifier; never the audience files-api. Default: FILES_API_RESOURCE, else derived from apiUrl, else production, https://files.centralcodestation.com/api
env no Where the defaults read their variables (below). Default process.env; {} ignores the environment
tenantId, applicationId no The default context. A platform consumer omits them and passes context per call
scope no Default files:manage; files:read, files:config.manage, or an array
maxRetries no Default 3: retries after the first attempt, for 5xx and network errors on retryable requests; 0 disables
fetch no A fetch of your own (tests, proxies); also used for the PUTs of upload()

Omit issuerUrl, apiUrl and resource and the client talks to the production platform. For another environment, set these variables in your backend’s environment, or pass the options explicitly (an explicit option always wins):

Variable Effect
CCS_ENVIRONMENT local (http://localhost:50300, http://localhost:50600), staging (https://auth.staging.centralcodestation.com, https://files.staging.centralcodestation.com) or production (the default)
FILES_API_URL The File Service API base URL, such as https://files.centralcodestation.com/api/v1
FILES_API_RESOURCE The Files API’s resource identifier, such as https://files.centralcodestation.com/api
CCS_ISSUER_URL The Identity Server’s bare origin

The same addresses are exported for code that needs them without a client, such as a token route that exchanges a user’s token onto the Files resource. Neither function reads process.env on its own; pass it:

import { defaultIssuerUrl, FILES_ORIGINS, filesEndpoints } from '@central-code-station/ccs-files-client';
const { apiUrl, resource } = filesEndpoints({ env: process.env });
const issuer = defaultIssuerUrl(process.env);
FILES_ORIGINS.local; // 'http://localhost:50600'; IDENTITY_ORIGINS and FILES_AUDIENCE ('files-api') too

filesEndpoints (and the client) decide each value on its own: the option (apiUrl, resource) first, then FILES_API_URL or FILES_API_RESOURCE, then CCS_ENVIRONMENT, then production. Set only one of the two and the other is derived from it when it has the usual shape (…/api/v1 and …/api); otherwise it is the environment’s. A value that is not an absolute http(s) URL, or an unknown CCS_ENVIRONMENT, throws with the variable’s name, so a typo fails at startup.

files.endpoints tells which addresses the client ended up with. Construct one client per process and reuse it: it gets its client_credentials token lazily, caches it until 60 seconds before it expires, and shares one pending token request between concurrent calls.

Request and response objects are the service’s own snake_case wire format, unchanged. Only the constructor options, the trailing context argument and the library’s own option objects ({ ifMatch }, { ttlSeconds }, upload options) are camelCase. The wire type File shadows the global File; import it as FileRecord when you need both.

Area Calls
Uploads upload() (the whole protocol), uploads.create, status, parts, complete, abort
Files files.get, listByExternalRef, update, move, copy, delete, restore, purge, downloadUrl, urls, preview, waitUntilReady, effectivePermissions, effectivePermissionsBatch
Legal hold files.hold.place, files.hold.release; holds.list (see Legal hold)
Versions and renditions files.versions.list, create, get, restore, pin, delete; files.renditions.list, regenerate
Folders and jobs folders.root, create, get, listByExternalRef, children, path, update, move, copy, delete, restore, usage; jobs.get, jobs.wait. create and update take managed (see Application-managed folders)
Permissions permissions.get, replace, grant, revoke, roles
Search search, tags, taxonomies.get, taxonomies.set
Shares and trash shares.create, list, listAll, revoke; trash.list, empty
Administration settings.public, get, update; usage (with daily), tenantUsage, audit.list, forFile, forFolder; capabilities
Platform administration platform.tenants, tenant, setTenantSettings, defaults, setDefaults, usage, tenantUsage, tenantAudit, exportTenantAudit, exportTenantUsage; platform.storage.accounts (list, create, get, update, delete, verify), platform.storage.tenant, assign, platform.storage.migrations (start, list, get)
Webhooks webhooks.get, set, deliveries; FilesClient.verifyWebhookSignature (see Webhooks)

The role matrix is for people on the console and is not in this library. files.checkout, checkin and discardCheckout exist for check-out, which is Coming: they reject with FilesNotImplementedError until the service serves them.

  • Application-managed folders: folders.create({ ..., managed: true }) and folders.update(folderId, { managed }) mark and unmark a folder; File and Folder gain managed and managed_by (ManagedBy); FilesForbiddenError.managedFolderId and isFolderManagedError(error) read a 403 folder_managed. See below and Application-managed folders.
  • Legal hold: files.hold.place(fileId, { reason, until?, acting_user_id? }), files.hold.release(fileId, { reason? }) and holds.list({ placed_by?, after?, limit? }); File.hold is { reason, placed_by, placed_at, until } or null. files.purge and files.versions.delete of a held file reject with FilesConflictError file_on_hold, the hold in error.body.hold. They need files:manage. See Legal hold.
  • Usage trend: usage() answers daily, the last 30 days (DailyUsage: date, bytes, rendition_bytes, trashed_bytes, files, versions, quota_bytes).
  • Platform administration: the platform namespace, below.
  • Search wording: in: ['name'] matches names that start with the query; see Search.
  • URLs: treat every URL as opaque; a public file’s public_url can change (Where files are stored).
  • Processing: processing.scan (clean, infected, skipped, failed) and processing.text, typed as ScanStatus and ProcessingStepStatus; the mp4_720p and mp4_1080p renditions with duration_ms, and error on a skipped or failed rendition; media typed as FileMedia, with captured_at, camera, title and author. See Processing status.
  • Content search: search({ q, in: ['content'] }), hits with highlights: [{ field, snippet }]. See Searching inside documents.
  • Webhooks: the configuration’s secret_set, consecutive_failures, last_delivery_at, disabled_at, updated_at; delivery rows with event_id, error, duration_ms, next_attempt_at and the statuses delivered, retrying, failed.
  • Corrected types: Share has target: { type, id }, password_protected, downloads and access_count; AccessEntry.granted_by is { user_id, client_id }; Preview.kind is image, video, audio, pdf, text or none; an upload ticket’s status can be failed; Folder has depth, state and busy; a role of permissions.roles() has display_name. Fields the 1.0.0 types named but the service never sent are removed. The changelog lists every change.

files.upload(source, input, options?) creates the ticket with the source’s size, PUTs the bytes (a single PUT with exactly the ticket’s headers, or parts with concurrency in flight, part URLs beyond the first 100 fetched in batches) and completes. Each PUT is retried up to 5 times with backoff; an expired part URL is fetched again once. When it gives up it aborts the ticket and throws FilesUploadError (partNumber, uploadId, cause). A completion whose answer was lost is recovered from the ticket’s status. See Uploading on behalf of users.

files.files.waitUntilReady(fileId, { timeoutMs, pollMs, signal }) resolves when no processing step is pending, and rejects with FilesConflictError whose code is file_quarantined, file_rejected (refused by processing), file_trashed or processing_failed (body.step: a step failed and processing stopped there, such as a scan under scan_failed_policy: block), or after timeoutMs with status 408 and code wait_timeout. Since 1.1.0 a step that failed without stopping processing (a rendition, the metadata, text extraction) does not reject: the file it resolves with shows it in processing. jobs.wait(jobId) resolves when a job is done and rejects with job_failed or wait_timeout. file_rejected, processing_failed, job_failed and wait_timeout are the library’s own codes; the last file or job is in error.body.

A folder your client marks managed is read-only for people, so a change in the Files console cannot break the file ids your database keeps; your client changes it as before. Mark one top folder per kind of record and create the records’ folders below it:

managed-folders.ts
import { isFolderManagedError, type File, type Folder } from '@central-code-station/ccs-files-client';
import { db } from '../shared/app';
import { files } from './files-client';
// One managed top folder per kind of record: people see it in the console but cannot change it.
async function postsFolder(): Promise<Folder> {
const { items } = await files.folders.listByExternalRef('posts:root');
if (items.length > 0) return items[0];
return files.folders.create({ parent_id: 'root', name: 'posts', external_ref: 'posts:root', managed: true });
}
// /posts/<post id>, managed through its parent and found again by your own id.
async function postFolder(postId: string): Promise<Folder> {
const ref = `post:${postId}`;
const { items } = await files.folders.listByExternalRef(ref);
if (items.length > 0) return items[0];
const parent = await postsFolder();
return files.folders.create({ parent_id: parent.folder_id, name: postId, external_ref: ref });
}
// Your backend uploads as before. Keep the file id in your database, never the path.
export async function attachToPost(postId: string, authorId: string, bytes: Buffer, name: string, mimeType: string): Promise<File> {
const folder = await postFolder(postId);
const file = await files.upload(bytes, { name, mime_type: mimeType, folder_id: folder.folder_id, owner_id: authorId, acting_user_id: authorId, external_ref: `post:${postId}` });
await db.posts.addMedia(postId, { file_id: file.file_id, kind: file.kind, mime_type: file.mime_type, name: file.name });
return file; // file.managed is true; file.managed_by.managed_folder_id is the posts folder
}
// Nobody, your backend included, creates a share link inside a managed folder.
export async function shareLink(fileId: string): Promise<string | null> {
try {
const share = await files.shares.create({ fileId }, { actions: ['view'] });
return share.url;
} catch (error) {
if (isFolderManagedError(error)) return null; // error.managedFolderId names the folder that carries the mark
throw error;
}
}
  • managed on CreateFolderInput and UpdateFolderInput: true marks, false unmarks. It needs files:manage, and the root cannot be managed (400 invalid_request).
  • File.managed and Folder.managed are true for the marked folder and everything below it; managed_by is { client_id, name, managed_folder_id, managed_at } (ManagedBy), or null.
  • Your client creates share links inside a managed folder as usual (people cannot). isFolderManagedError(error) narrows to it and error.managedFolderId names the folder that carries the mark (null on other codes).
  • The tenant owner’s emergency release is a user route; this library has no call for it.

Every error extends FilesApiError, with status, code (the body’s error), body (the whole error body) and retryAfterSeconds on 429.

Class Status Typical codes
FilesUnauthorizedError 401 invalid_token; invalid_client from the token endpoint
FilesForbiddenError 403 insufficient_scope, client_context_mismatch, missing_action, missing_capability, partial_permission, tenant_suspended, platform_admin_required, folder_managed (managedFolderId), principal_not_allowed
FilesNotFoundError 404 file_not_found, folder_not_found, rendition_not_available, storage_account_not_found, storage_migration_not_found
FilesConflictError 409 name_conflict, scan_pending, file_quarantined, file_trashed, file_on_hold (body.hold), upload_not_open, version_is_current, storage_account_in_use, storage_account_not_verified, storage_migration_in_progress
FilesPreconditionFailedError 412 revision_mismatch
FilesPayloadTooLargeError 413 file_too_large, quota_exceeded (numbers in body)
FilesUnprocessableError 422 type_not_allowed, size_mismatch, too_many_file_ids, webhook_unreachable, storage_account_verification_failed, storage_adapter_not_allowed
FilesRateLimitedError 429 rate_limited; retryAfterSeconds from Retry-After
FilesNotImplementedError 501 not_implemented
FilesApiError any other invalid_request, webhook_url_not_allowed (400), share_gone (410), storage_unavailable (503)

Besides those: FilesContextError (no context for the call, thrown before any request) and FilesUploadError. toFilesApiError(status, body, fallback, retryAfter?) builds the matching class.

  • Retries: 5xx (never 501) and network errors, up to maxRetries, with a short exponential backoff and jitter, for reads and idempotent writes: GET, PUT, PATCH, DELETE, and the POSTs that only read or mint (files.urls, files.effectivePermissionsBatch, uploads.parts). The writes that create (uploads.create, files.versions.create, folders.create, folders.copy, files.copy, shares.create) carry an Idempotency-Key made before the first attempt and reused on every retry, so they retry too. Every other POST (uploads.complete, restores, purge, permissions.grant, trash.empty, platform.storage.accounts.create, platform.storage.migrations.start) is never retried; platform.storage.accounts.verify is retried like a read. 4xx answers, 429 included, are never retried.
  • 401: drops the cached token and replays the request once with a fresh one; a second 401 is thrown.

client.platform is for a client of the Files platform application itself with files:config.manage, typically a platform operator’s automation; every other client, a tenant’s own included, gets FilesForbiddenError platform_admin_required. Its calls take no context.

platform-report.ts
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
import { FilesClient } from '@central-code-station/ccs-files-client';
// Only a client of the Files platform application itself passes; any other gets platform_admin_required.
const ops = new FilesClient({
issuerUrl: process.env.CCS_ISSUER_URL!,
clientId: process.env.CCS_FILES_CLIENT_ID!,
clientSecret: process.env.CCS_FILES_CLIENT_SECRET!,
apiUrl: process.env.FILES_API_URL!,
resource: process.env.FILES_API_RESOURCE!,
scope: 'files:config.manage', // no tenantId or applicationId: platform calls take no context
});
// The ten tenants that grew most in 30 days, then one tenant's audit log of the month as CSV.
export async function monthlyReport(tenantId: string): Promise<void> {
const trends = await ops.platform.usage({ days: 30, sort: 'growth_bytes', limit: 10 });
for (const t of trends.items) console.log(t.name, t.growth_bytes, t.quota_ratio);
const audit = await ops.platform.exportTenantAudit(tenantId, { from: '2026-10-01', to: '2026-10-31', format: 'csv' });
if (audit.truncated) console.warn('more than the export holds: narrow the filters');
if (!audit.body) return;
const body = Readable.fromWeb(audit.body as WebReadableStream<Uint8Array>);
await pipeline(body, createWriteStream(audit.filename ?? 'audit.csv')); // every read and export is audited
}
  • Tenants and defaults: tenants({ q }), tenant(id), setTenantSettings(id, patch), defaults(), setDefaults(patch).
  • Usage trends: usage({ days, sort, limit }) ranks the tenants (sort: used_bytes, growth_bytes or quota_ratio) with the platform’s totals; tenantUsage(id, { days }) gives a tenant’s daily series and each application’s.
  • A tenant’s audit log: tenantAudit(id, query) with the filters of audit.list plus application_id.
  • Exports: exportTenantAudit(id, { ...filters, format }) and exportTenantUsage(id, { from, to, format }) resolve with a FilesExport: content_type, filename, truncated, the raw body stream, text() and lines() (read it once). Every read and export is audited. See Platform administration.
  • Storage: storage.accounts (list, create, get, update, delete, verify), storage.tenant(id), storage.assign(id, { storage_account_id }), and moves with storage.migrations (start, list, get). Creating an account and starting a move are never retried; verify is. See Storage accounts.

Every method takes a trailing context?: { tenantId, applicationId }; it wins over the client’s defaults. A platform consumer constructs the client without defaults and passes the context on every call. See Contexts and tokens.