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.
Install
Section titled “Install”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.comnpm install @central-code-station/ccs-files-clientNo 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.
Configure
Section titled “Configure”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') toofilesEndpoints (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.
What it covers
Section titled “What it covers”| 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.
New in 1.3.0
Section titled “New in 1.3.0”- Application-managed folders:
folders.create({ ..., managed: true })andfolders.update(folderId, { managed })mark and unmark a folder;FileandFoldergainmanagedandmanaged_by(ManagedBy);FilesForbiddenError.managedFolderIdandisFolderManagedError(error)read a 403folder_managed. See below and Application-managed folders.
New in 1.2.0
Section titled “New in 1.2.0”- Legal hold:
files.hold.place(fileId, { reason, until?, acting_user_id? }),files.hold.release(fileId, { reason? })andholds.list({ placed_by?, after?, limit? });File.holdis{ reason, placed_by, placed_at, until }ornull.files.purgeandfiles.versions.deleteof a held file reject withFilesConflictErrorfile_on_hold, the hold inerror.body.hold. They needfiles:manage. See Legal hold. - Usage trend:
usage()answersdaily, the last 30 days (DailyUsage:date,bytes,rendition_bytes,trashed_bytes,files,versions,quota_bytes). - Platform administration: the
platformnamespace, 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_urlcan change (Where files are stored).
New in 1.1.0
Section titled “New in 1.1.0”- Processing:
processing.scan(clean,infected,skipped,failed) andprocessing.text, typed asScanStatusandProcessingStepStatus; themp4_720pandmp4_1080prenditions withduration_ms, anderroron a skipped or failed rendition;mediatyped asFileMedia, withcaptured_at,camera,titleandauthor. See Processing status. - Content search:
search({ q, in: ['content'] }), hits withhighlights: [{ field, snippet }]. See Searching inside documents. - Webhooks: the configuration’s
secret_set,consecutive_failures,last_delivery_at,disabled_at,updated_at; delivery rows withevent_id,error,duration_ms,next_attempt_atand the statusesdelivered,retrying,failed. - Corrected types:
Sharehastarget: { type, id },password_protected,downloadsandaccess_count;AccessEntry.granted_byis{ user_id, client_id };Preview.kindisimage,video,audio,pdf,textornone; an upload ticket’sstatuscan befailed;Folderhasdepth,stateandbusy; a role ofpermissions.roles()hasdisplay_name. Fields the 1.0.0 types named but the service never sent are removed. The changelog lists every change.
Uploads in one call
Section titled “Uploads in one call”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.
Application-managed folders
Section titled “Application-managed folders”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:
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; }}managedonCreateFolderInputandUpdateFolderInput:truemarks,falseunmarks. It needsfiles:manage, and the root cannot be managed (400invalid_request).File.managedandFolder.managedaretruefor the marked folder and everything below it;managed_byis{ client_id, name, managed_folder_id, managed_at }(ManagedBy), ornull.- Your client creates share links inside a managed folder as usual (people cannot).
isFolderManagedError(error)narrows to it anderror.managedFolderIdnames the folder that carries the mark (nullon other codes). - The tenant owner’s emergency release is a user route; this library has no call for it.
Errors and retries
Section titled “Errors and retries”Every error extends FilesApiError, with status, code (the body’s error), body (the whole error body) and retryAfterSeconds on 429.
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 anIdempotency-Keymade 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.verifyis 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.
Platform administration
Section titled “Platform administration”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.
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_bytesorquota_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 ofaudit.listplusapplication_id. - Exports:
exportTenantAudit(id, { ...filters, format })andexportTenantUsage(id, { from, to, format })resolve with aFilesExport:content_type,filename,truncated, the rawbodystream,text()andlines()(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 withstorage.migrations(start,list,get). Creating an account and starting a move are never retried;verifyis. See Storage accounts.
Contexts and platform consumers
Section titled “Contexts and platform consumers”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.