Skip to content

ccs-files-user-client

@central-code-station/ccs-files-user-client is the File Service client for a web app’s signed-in user. It runs the upload protocol for you (a ticket, one PUT or parallel parts straight to the store, with progress, retries, pause, resume and cancel, then the completion) and wraps the rest of the API the user may call: folders, files, versions, search, previews and download URLs, access entries, share links and the trash. It never holds a client secret; your backend is ccs-files-client. This page covers installation, configuration and the behaviour you rely on; every type and signature is in the generated reference, the release history in the changelog. For a walk-through, start with the web quickstart.

Published privately on GitHub Packages, like the Node client. Map the scope in your project’s .npmrc and 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-user-client

No runtime dependencies. Three entries: the framework-free core (@central-code-station/ccs-files-user-client), Angular (…/angular, Angular 17 or newer, Signals) and React (…/react, React 18 or newer, hooks). Angular and React are optional peer dependencies. The core needs fetch and Blob; it uses XMLHttpRequest for the store PUTs so it can report bytes in flight, and falls back to fetch (progress per finished part) where there is none.

The client gets the user’s File Service token from your backend’s token route:

files.ts
import { createTokenProvider, FilesClient } from '@central-code-station/ccs-files-user-client';
import { getAppAccessToken } from '../shared/web-app';
// Your backend's token route (see "User tokens"), called with your app's own session.
export const tokens = createTokenProvider({
url: '/api/files-token',
headers: async () => ({ Authorization: `Bearer ${await getAppAccessToken()}` }),
// credentials: 'include', // instead of headers, for a cookie session
});
// The route answers api_url, tenant_id and application_id, so the client configures itself. The token is cached until
// a minute before it expires, renewed with one request at a time, and asked for again once after a 401.
export const files = await FilesClient.fromTokenRoute(tokens);
// Or configure it yourself:
// new FilesClient({ apiUrl, tenantId: 'fitness-co', applicationId: 'fitness-app', getAccessToken: tokens.getAccessToken });
// (apiUrl is optional: omitted, the production File Service.)
// On sign-out: cancels the uploads in progress, clears the URL cache and the token.
export const signOut = () => files.signOut();

createTokenProvider(options):

Option Required Meaning
url yes Your token route, for example /api/files-token
headers no Headers for the route (your app’s own session), an object or a function read on every request
credentials no The fetch credentials mode for a cookie session, for example 'include'
method no 'GET' (default) or 'POST'
expiryMarginSeconds no Renew this long before expires_in runs out. Default 60
fetch no Your own fetch

It caches the token, shares one pending request between callers, and clear() forgets it. FilesClient.fromTokenRoute(tokens, overrides?) asks the route once and configures the client from its optional api_url, tenant_id and application_id; anything in overrides wins.

new FilesClient(options):

Option Required Meaning
apiUrl no Default: production, https://files.centralcodestation.com/api/v1. A browser reads no environment: pass it for another environment, or use fromTokenRoute, whose route sends api_url
tenantId, applicationId yes The context. One client per context and signed-in user
getAccessToken yes Returns the user’s File Service token; normally tokens.getAccessToken
concurrency no Parts in flight per multipart upload. Default 4
checksum no Send content_sha256, computed in the browser, for files up to 256 MiB. Default false (it reads the file twice)
maxRetries no Retries of an API request for 5xx and network errors. Default 3; 0 disables
xhrFactory no Builds the XMLHttpRequest of each store PUT
fetch no Your own fetch, for tests or server rendering
debug no Log to the console. Default false

Request and response objects are the service’s snake_case wire format, unchanged; constructor options and the library’s own option objects are camelCase. The wire type File shadows the DOM’s File: import it as FileRecord when you need both.

Area Calls
Uploads upload(file, input?, options?), resumeUpload(snapshot, file), uploads.active(), uploads.views(), uploads.on('change', …); static FilesClient.uploadFromTicket(ticket, file, options?)
Files files.get, update, move, copy, delete, restore, download, downloadUrl, myPermissions, waitUntilReady
Legal hold files.hold.place(fileId, { reason, until? }), files.hold.release(fileId, { reason? }), holds.list({ placed_by?, after?, limit? }), for users with files.manage (see Legal hold)
Versions and renditions files.versions.list, get, upload, restore, pin, delete; files.renditions.list, regenerate
Showing files preview(fileId, { version }), urls(fileIds, { rendition, disposition }), invalidate(fileId), and the helper playbackSources(preview)
Folders folders.root, create, get, children, path, update, move, copy, delete, restore; releaseManagement for the tenant owner (see Application-managed folders)
Search search(query) (in: ['name'] matches names that start with the query; keywords and content match whole words; highlights), tags(query)
Sharing permissions.get, replace, grant, revoke, roles; shares.create, list, revoke
The rest trash.list, me.capabilities(), settings.public(), signOut(), dispose(); the helpers isReadOnly(item) and isFolderManagedError(error)
  • Application-managed folders: File and Folder gain managed and managed_by (ManagedBy); isReadOnly(item) tells whether people may change an item; folders.releaseManagement(folderId, { reason }) is the tenant owner’s emergency release; FilesForbiddenError.managedFolderId and isFolderManagedError(error) read a 403 folder_managed. See below.
  • Legal hold: File.hold is typed (FileHold: reason, placed_by, placed_at, until); files.hold.place, files.hold.release and holds.list for a user with files.manage (anyone else gets missing_capability). Deleting a version of a held file rejects with FilesConflictError file_on_hold, the hold in error.body.hold.
  • Search wording: in: ['name'] is a prefix match, ignoring case; total is exact up to 10,000 and null above (Search).

client.upload(file, input, options) returns an UploadHandle at once. input is the upload request without name, size and mime_type, which default to the file’s own. The handle has id, name, size, fileId and uploadId (once the ticket exists), state, progress ({ loaded, total, parts: { done, total } }, bytes in flight included), error, done (a promise of the file), pause(), resume(), cancel(), on('progress' | 'state' | 'error', handler) (returns an unsubscribe function) and serialize().

Behaviour Rule
States creating, uploading, paused, completing, done, failed, cancelled
Refusals A ticket refused for quota_exceeded, file_too_large or type_not_allowed fails the upload before any byte is sent
Single PUT Exactly the ticket’s headers, nothing else: no credentials, no Authorization
Multipart concurrency parts in flight; the ticket’s first part URLs, then batches of 100; each PUT retried 5 times with backoff (1 to 30 seconds) on network errors and 5xx; a part URL that expired is requested again once; every part’s ETag collected
Pause Stops the PUTs in flight; their bytes are sent again on resume. No effect while completing
Failure state: 'failed', done rejects; the ticket is kept, so resume() continues and done becomes a new promise
Cancel Stops the PUTs and releases the ticket; done rejects with UploadCancelledError
Resume after a reload resumeUpload(snapshot, file) with the same file picked again: an open multipart ticket continues from the parts the store holds, a completed one resolves with its file, anything else starts over. Another file (size or lastModified) throws UploadFileMismatchError
Snapshots A client upload’s snapshot holds no signed URL

done resolves when the upload is complete: the file is available and processing has started. files.waitUntilReady(fileId) polls until it finished, or listen for file.ready through the Notification Service.

FilesClient.uploadFromTicket(ticketOrSnapshot, file, options) runs the same engine on a ticket your backend created with the Node client, with no File Service token in the app. done resolves with { upload_id, file_id, method, parts } for your backend to complete. options.partUrls({ from, count }) asks your backend for more part URLs (above the first 100, or when one expired); without it a missing or expired part URL fails the upload. Cancelling only stops the PUTs: your backend releases the ticket. A ticket snapshot keeps the ticket as handed out, signed URLs included, because nothing else could mint them again: keep it with the user’s session data. See the web quickstart.

preview(fileId) answers what to show (kind: image, video, audio, pdf, text or none) with a signed URL; urls(fileIds, { rendition }) mints a feed page in one request, one entry per id in order. Both are cached per file, version, rendition and disposition until a minute before the URLs expire; invalidate(fileId) drops a file’s entries, and so do the client’s own updates, deletes, version uploads and restores. files.download(fileId) always mints a fresh URL (each download is audited) and points the window at it; { navigate: false } only returns it. playbackSources(preview) lists the playable URLs of a video, best first: the ready mp4_1080p and mp4_720p renditions, then the URL the service picked. See Previews and renditions.

A folder a tenant’s backend marks managed, and everything below it, is read-only for every user, the tenant owner included: uploads, versions, renames, moves, edits, copies into it, trash, restore, purge, access entries and share links answer 403 folder_managed. Viewing, downloads, previews, search, copying out and legal holds work as usual. my_actions still lists what the user holds, so hide the editing controls when isReadOnly(item) is true. See Application-managed folders.

managed.ts
import { isFolderManagedError, isReadOnly, type File, type Folder } from '@central-code-station/ccs-files-user-client';
import { files } from './files-client';
// Decide the editing controls from `managed`, not from my_actions alone.
export function canRename(item: File | Folder): boolean {
return !isReadOnly(item) && item.my_actions.includes('edit');
}
// "Managed by Fitness": the name, or the client id when the mark has none.
export function managerLabel(item: File | Folder): string | null {
return item.managed_by ? `Managed by ${item.managed_by.name ?? item.managed_by.client_id}` : null;
}
// A change can still meet a mark set after the item was loaded: show why.
export async function rename(fileId: string, name: string): Promise<string | null> {
try {
await files.files.update(fileId, { name });
return null;
} catch (error) {
if (isFolderManagedError(error)) return `Read-only: managed through folder ${error.managedFolderId}`;
throw error;
}
}
// The tenant owner's emergency release, on the folder that carries the mark.
export async function release(item: File | Folder, reason: string): Promise<Folder | null> {
const { tenant_owner } = await files.me.capabilities();
if (!tenant_owner || !item.managed_by) return null;
return files.folders.releaseManagement(item.managed_by.managed_folder_id, { reason });
}

folders.releaseManagement(folderId, { reason }) answers the Folder. It is for the tenant owner only (anyone else gets tenant_owner_required), needs a reason of 1 to 500 characters, and must name the folder that carries the mark, managed_by.managed_folder_id (400 invalid_request otherwise). It is never retried.

Entry API
…/angular createFiles(optionsOrClient, destroyRef?) → { client, uploads, upload, resumeUpload, signOut }; uploads is a Signal of immutable upload views. A plain factory: call it from your own service
…/react useFiles(options) → { client, error } (options, { client } or { tokens, overrides }); useUpload(client) → { start, resume, uploads }; usePreview(client, fileId, { version, refresh }) → { preview, loading, error, reload }

A client the hook or the factory built is disposed with the component or the injector: uploads in flight pause and their tickets stay resumable.

Every API failure is a FilesApiError (status, code = the body’s error, body, retryAfterSeconds) of the class that matches its status, the same classes as the Node client: FilesUnauthorizedError (401), FilesForbiddenError (403, for example folder_managed with managedFolderId), FilesNotFoundError (404), FilesConflictError (409, for example scan_pending or file_on_hold), FilesPreconditionFailedError (412), FilesPayloadTooLargeError (413), FilesUnprocessableError (422), FilesRateLimitedError (429), FilesNotImplementedError (501). FilesContextError: no tenant or application. FilesUploadError (partNumber, uploadId, cause): the bytes could not be moved; its subclasses UploadFileMismatchError (code: 'upload_file_mismatch') and UploadCancelledError (code: 'upload_cancelled').

  • Token: a 401 asks getAccessToken({ forceRefresh: true }) once and replays; a second 401 is thrown.
  • Retries: 5xx (not 501) and network errors, up to maxRetries, on reads, idempotent writes and the writes that create (folders, copies, tickets, shares), which carry an Idempotency-Key fixed before the first attempt. 4xx answers, 429 included, are never retried.
  • Sign-out: signOut() cancels the uploads in progress, clears the URL cache and the token provider, and disposes the client. dispose() only pauses the uploads.

ES modules and CommonJS with type declarations, for the three entries, chosen by exports. It works with any bundler and with TypeScript’s nodenext, bundler and legacy node resolution, and unused entries tree-shake away. Load it one way per app: the two builds are separate copies and instanceof does not cross them.