@colixsystems/filestore-client
v0.5.0
Published
Typed client for the AppStudio Filestore subsystem (folders, file archive, previews, presigned object streaming, sharing, trash) at /filestore/*, plus BankID file signing at /signatures. snake_case JSON wire contract, no transform.
Readme
@colixsystems/filestore-client
Typed client for the AppStudio Filestore subsystem — the file archive mounted at /filestore/*. It covers:
- files — the file archive: list, get metadata, multipart upload, rename/move, soft-delete, and preview via
files.{list,get,upload,update,remove,preview}. - folders — folder tree CRUD via
folders.{list,create,update,remove}. - shares — folder-scoped permissions (the Filestore ACL) via
shares.{list,create,remove,sharedWithMe}. Permissions live on folders only (no file-level grant); each grant gives a user/group aVIEW/DOWNLOAD/MANAGEtier on a folder. Folder visibility (INHERIT/RESTRICTED) is set viafolders.update(id, { visibility }). - trash — the recycle bin: list trashed items, restore, and permanent purge via
trash.{list,restoreFile,restoreFolder,purgeFile,purgeFolder}. - presigned object streaming —
objectUrl(token)builds the byte-stream URL andfetchObject(token)fetches it.
What this is NOT. This is the Filestore subsystem client for external / server-side integrations and the widget runtime. It is not the Asset Manager. The widget host injects an instance into
WidgetContext.filestore, where the SDK hooksuseFilestoreFiles/useFilestoreFolders/useFileSignatureread it (mirroring how@colixsystems/datastore-clientbacksWidgetContext.datastore). For non-widget use you instantiate it yourself withcreateFilestoreClient({ baseUrl, getToken, getTenantId }).
Status
v0.5.0 — pre-publish. Not yet published to npm.
0.5.0 (breaking + additive): shares are now folder-scoped (the Filestore ACL, REQ-FSH). The polymorphic
target_type/target_idpair is gone:shares.list({ folder_id })lists a folder's grants,shares.create({ folder_id, subject_type, subject_id, permission })grants aVIEW/DOWNLOAD/MANAGEtier (re-granting updates the tier),shares.remove(id)revokes. Granting/listing/revoking is a MANAGE action on the folder (the backend 403s a non-manager, 404s an unseeable folder).shares.sharedWithMe()now returns{ folders, shares }(nofiles— permissions are folder-scoped). Folder visibility (INHERIT/RESTRICTED) is set via the existingfolders.update(id, { visibility }). Additive:signatures.roster(fileId, { limit, offset })— the MANAGE-gated signer roster (GET /signatures/roster): the file's folder audience, each annotated signed/not-signed,{ data, meta }withmeta.signed_count. Also:signatures.list(fileIds)is now self-scoped (the caller's own signatures only) — the backend contract tightened; the method signature is unchanged.
0.4.0 (fix):
presigned_urlon every file/preview the client returns is now absolutized against the client'sbaseUrlorigin. The backend mints presigned URLs as a relative path (/api/v1/filestore/object?token=…), which only resolves same-origin — so file thumbnails/downloads broke for a cross-origin web build (dev :5173 vs API :5000) and for the native export (a device has no page origin). The client now glues its own origin onto the relative path (leaving already-absolute URLs and same-origin relative bases untouched), so URLs work everywhere the client runs. Applies tofiles.{list,get,upload,update,preview},trash.list, andshares.sharedWithMe. No method signature changed.
0.3.0 (additive): added
signatures.list(fileIds)— a batch "is this file signed?" lookup (GET /signatures?subject_kind=file&file_ids=csv) returning the{ data, meta }envelope wheredatais the latest completedFileSignatureSummary({ signature_id, subject_file_id, signer_name, signed_at }) per accessible, signed file. Lets a file browser mark already-signed files without an N+1. No existing method changed.
0.2.0 (additive): added the
signaturesnamespace (REQ-SIGN) — BankID file signing over/signatures(NOT/filestore/*):signatures.initiate(fileId)→{ signature_id, order_ref, auto_start_token, qr, status };status(id)/cancel(id)/verify(id)key off the returnedsignature_id. The backend opens a sign order against theFsFile, hashes its bytes server-side, and verifies the proof offline before storing it. snake_case verbatim. No existing namespace changed. This release is also the first injected intoWidgetContext.filestore(read by the SDK filestore hooks), and corrects the multipart upload field-name docs to snake_case (space_type/owner_id/folder_id), matching what the controller actually reads.
Contract: snake_case JSON, no transform
The wire format for JSON bodies and query params is snake_case in both directions and that is the client contract too. The SDK does no case mapping:
- JSON request bodies are sent snake_case verbatim — you pass
{ space_type, owner_id, parent_folder_id }, not{ spaceType, ownerId, parentFolderId }. - Query params are sent snake_case verbatim —
space_type,owner_id,folder_id,parent_folder_id,file_id,type,q,limit,offset. - Response objects are returned snake_case verbatim — you read
row.created_at,row.owner_id,share.folder_id. (The one value the client rewrites ispresigned_url, resolved to an absolute URL against thebaseUrlorigin — see 0.4.0 above. It's a URL-origin fix, not a case transform.) - The list endpoints (
files.list,folders.list,shares.list,signatures.list,signatures.roster) return the{ data, meta }envelope verbatim (no unwrap).trash.listreturns{ files, folders }andshares.sharedWithMe()returns{ folders, shares }— those are NOT{ data, meta }envelopes.
Multipart upload field names
POST /filestore/files is multipart/form-data, which bypasses the backend's snake_case request middleware, so the file controller reads the form field names verbatim — and it reads them snake_case, the same as every JSON body. files.upload(formData) sends your FormData verbatim — you must use exactly these field names:
| FormData field | Required | Notes |
| --- | --- | --- |
| file | yes | The binary file part. |
| space_type | yes | e.g. PERSONAL / PROJECT. |
| owner_id | yes | The space owner id. |
| folder_id | no | Target folder id; omit for the space root. |
Multipart fields travel in whatever casing the controller reads them in (multipart is not transformed); here that is snake_case, matching the rest of the wire contract.
Public API
import {
createFilestoreClient,
FilestoreError,
NotFoundError,
ForbiddenError,
ValidationError,
GoneError,
PayloadTooLargeError,
UnsupportedMediaTypeError,
RateLimitedError,
ServerError,
} from "@colixsystems/filestore-client";
const client = createFilestoreClient({
baseUrl: "https://api.appstudio.io/api/v1",
getToken: () => "Bearer ...", // Studio or AppUser JWT; "Bearer " prefix added if missing
getTenantId: () => "tenant_abc", // x-tenant-id header
getRequestHeaders: ({ namespace, operation }) => ({ "X-Trace": "..." }), // optional
// fetchImpl defaults to globalThis.fetch
});
// Files — list is { data, meta } verbatim; query keys are snake_case.
const { data: files, meta } = await client.files.list({
space_type: "PERSONAL",
owner_id: "user_123",
folder_id: "folder_1", // optional
q: "invoice", // optional
type: "image", // optional
limit: 50,
offset: 0,
});
const file = await client.files.get("file_1");
// Upload — multipart. FormData field NAMES are snake_case (see table above).
const fd = new FormData();
fd.append("file", blob, "photo.jpg");
fd.append("space_type", "PERSONAL");
fd.append("owner_id", "user_123");
fd.append("folder_id", "folder_1"); // optional
const uploaded = await client.files.upload(fd);
await client.files.update("file_1", { name: "renamed.jpg", folder_id: "folder_2" });
await client.files.remove("file_1"); // soft delete -> trash
const preview = await client.files.preview("file_1");
// Folders
const { data: folders } = await client.folders.list({
space_type: "PROJECT",
owner_id: "project_9",
parent_folder_id: "folder_root", // optional
});
await client.folders.create({ name: "Docs", space_type: "PERSONAL", owner_id: "user_123", parent_folder_id: null });
await client.folders.update("folder_1", { name: "Renamed", parent_folder_id: "folder_0" });
await client.folders.remove("folder_1");
// Shares
const { data: shares } = await client.shares.list({ target_type: "FILE", target_id: "file_1" });
await client.shares.create({
target_type: "FILE",
target_id: "file_1",
subject_type: "USER",
subject_id: "user_456",
permission: "READ",
});
await client.shares.remove("share_1");
const inbox = await client.shares.sharedWithMe(); // { files, folders, shares } — AppUser only
// Trash
const { files: trashedFiles, folders: trashedFolders } = await client.trash.list({
space_type: "PERSONAL",
owner_id: "user_123",
});
await client.trash.restoreFile("file_1");
await client.trash.restoreFolder("folder_1");
await client.trash.purgeFile("file_1"); // permanent
await client.trash.purgeFolder("folder_1"); // permanent, recursive
// Presigned object streaming — the HMAC token IS the auth (no headers sent).
const href = client.objectUrl(token); // build a URL for <img>/<a>
const res = await client.fetchObject(token); // fetch the raw byte stream (Response)
// Signatures (BankID file signing — REQ-SIGN; hits /signatures, not /filestore)
const order = await client.signatures.initiate("file_1"); // { signature_id, qr, ... }
const st = await client.signatures.status(order.signature_id);
await client.signatures.cancel(order.signature_id);
const verdict = await client.signatures.verify(order.signature_id); // { valid, content_status, ... }Surface
| Method | HTTP | Returns |
| --- | --- | --- |
| files.list(query) | GET /filestore/files | Page<FilestoreFile> ({ data, meta }) |
| files.get(id) | GET /filestore/files/{id} | FilestoreFile |
| files.upload(formData) | POST /filestore/files (multipart) | FilestoreFile |
| files.update(id, body) | PUT /filestore/files/{id} | FilestoreFile |
| files.remove(id) | DELETE /filestore/files/{id} | void (soft delete) |
| files.preview(id) | GET /filestore/files/{id}/preview | Preview |
| folders.list(query) | GET /filestore/folders | Page<Folder> |
| folders.create(body) | POST /filestore/folders | Folder |
| folders.update(id, body) | PUT /filestore/folders/{id} | Folder |
| folders.remove(id) | DELETE /filestore/folders/{id} | void |
| shares.list(query) | GET /filestore/shares | Page<Share> |
| shares.create(body) | POST /filestore/shares | Share |
| shares.remove(id) | DELETE /filestore/shares/{id} | void |
| shares.sharedWithMe() | GET /filestore/shared-with-me | { files, folders, shares } (AppUser only) |
| trash.list(query) | GET /filestore/trash | { files, folders } |
| trash.restoreFile(id) | POST /filestore/trash/files/{id}/restore | FilestoreFile |
| trash.restoreFolder(id) | POST /filestore/trash/folders/{id}/restore | Folder |
| trash.purgeFile(id) | DELETE /filestore/trash/files/{id} | void (permanent) |
| trash.purgeFolder(id) | DELETE /filestore/trash/folders/{id} | void (permanent, recursive) |
| objectUrl(token) | builds GET /filestore/object?token=... | string (no request) |
| fetchObject(token) | GET /filestore/object?token=... | Response (raw byte stream) |
Query params (snake_case on the wire)
| Endpoint | Required | Optional |
| --- | --- | --- |
| files.list | space_type, owner_id | folder_id, q, type, limit, offset |
| folders.list | space_type, owner_id | parent_folder_id, q, limit, offset |
| shares.list | target_type, target_id | limit, offset |
| trash.list | space_type, owner_id | — |
Factory options
| Option | Type | Notes |
| --- | --- | --- |
| baseUrl | string | Required. The API base (e.g. .../api/v1). |
| getToken | () => string \| Promise<string> | Required. Studio or AppUser JWT; Bearer prefix added if missing. |
| getTenantId | () => string \| Promise<string> | Required. Returns the x-tenant-id value. |
| getRequestHeaders | ({ namespace, operation }) => object \| Promise<object> | Optional. Extra headers merged per request. namespace is one of files / folders / shares / trash. Not applied to objectUrl / fetchObject (the presigned token is the auth). |
| fetchImpl | typeof fetch | Optional. Defaults to globalThis.fetch. |
Transport
| Concern | Behaviour |
| --- | --- |
| Auth header | From getToken (host-injected); Bearer prefix normalised. Not sent on fetchObject (token is the auth). |
| Tenant header | x-tenant-id from getTenantId. AppUser JWTs make their own tenantId authoritative server-side. |
| Multipart | files.upload sends FormData verbatim; no hand-set content-type so fetch supplies the boundary. |
| Retries | Idempotent GETs retried 3× with exponential backoff (200/400/800 ms). Mutations and uploads are not retried. |
| Timeouts | 10 s default, configurable per call via timeoutMs. |
| Error model | Typed FilestoreError hierarchy: NotFoundError (404), ForbiddenError (403), ValidationError (400), GoneError (410 — expired object token), PayloadTooLargeError (413 — file over the 5 GB cap), UnsupportedMediaTypeError (415), RateLimitedError (429), ServerError (5xx). |
| Platform | Browser and React Native / Node 18+ (uses fetch, FormData, AbortController). |
Dependencies
None. The client uses only platform fetch, FormData, and AbortController, all available in modern browsers, Node 18+, and React Native.
Tests
node --test srcFully self-contained — no npm install, no cross-package deps.
