Offer and choose files across applications
Let people add files from any Cloud application with one chooser, and implement the file-provider contract so your application can offer its files.
A file provider lets other applications work with the files your application stores. A consumer browses your folders, opens a file, and, if you allow it, saves a new file into a folder. Any application that publishes a compatible declaration becomes a provider, with the same contract and no special treatment. Files is one.
Most applications only consume: their upload action calls chooseFiles() and
people add files from this device or from any provider. Implement the contract
when your application stores files that people want to use elsewhere.
The contract is a set of schemas exported from @k2b/cloud/contracts, built
from ordinary capabilities and
binary streams. Your
application keeps its own identifiers, permissions, storage, and routes.
Add files from providers
Call chooseFiles() from @k2b/cloud/browser/files in the click or key
handler of your upload action, and pass the result to the upload path you
already have:
import { chooseFiles } from "@k2b/cloud/browser/files";
const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
const attach = async () => {
const files = await chooseFiles({ multiple: true, maxBytes: MAX_ATTACHMENT_BYTES });
if (files.length > 0) await uploadAttachments(files);
};It resolves ordinary File objects with name, type, size, and modification
time, or [] when the person cancels. Your limits, progress display,
permission checks, and scans keep working on them unchanged. Mail attaches
files this way.
- No providers: it opens the device's file dialog directly, as an
<input type="file">would. That dialog needs the user activation of the click, so do notawaitanything before calling it. - With providers: it opens one chooser. This device comes first, then every provider. Inside a provider, people browse folders page by page, filter by name, and choose files. See the file chooser for the presentation.
accept(<input accept>syntax) andmaxByteslimit what can be chosen. Provider files that do not match are shown disabled with the reason.maxBytesis the most you take from providers in one choice: each file and all chosen files together stay within it. A single file is also limited by the provider's read limit; Files reads up to 50 MiB. Pass your real budget, for example the attachment limit of a draft. Files from the device are not checked here, so keep your own checks.multipleallows several files; it defaults tofalse.signalcloses the chooser, or stops waiting for the device's dialog, and resolves[].- Reads run through the provider's read stream, at most two at a time,
with progress per file. Cancel stops them. Each read checks the file's
current size and type again before any byte moves, and a body that differs
from the announced size fails instead of arriving cut off. The chosen files
stay in browser memory until your upload has read them, at most
maxBytestogether. WithoutmaxBytes, only the provider's limit per file applies. - Access: every folder page and every read is an ordinary capability call as the signed-in person; the provider authorizes each one, and Cloud records it like any other call. Showing a provider is not a grant.
A chosen file is a copy. chooseFiles() does not return where it came from,
and later changes in the provider do not reach your copy. Keep one upload
action: do not add a second "From Cloud" button next to it.
Functions
| Function | Kind | Input schema | Result schema | Stream | Files ID |
|---|---|---|---|---|---|
list |
Query | FileProviderListInputSchema |
FileProviderListDataSchema |
none | provider.list |
read |
Query | FileProviderReadInputSchema |
FileProviderReadDataSchema |
read | content.read |
save |
Action, optional | FileProviderSaveInputSchema |
FileProviderSaveDataSchema |
write | provider.save |
fileProvider bundles the kind, stream direction, and schemas of each
function, and FILE_PROVIDER_FUNCTIONS lists their names. save must declare
idempotency: "required", as every write stream must.
listreturns one page of a folder:writable, up to 100items, andnext. Withoutparent, it returns your root. Folders may be virtual: Files shows a person's storage bases as root folders.queryfilters names inside the folder;limitis 1 to 100 and defaults to 50.cursorcontinues from the previous page; send it with the sameparent,query, andlimit. A provider may reject a cursor with other values; Files answers409 cursor_invalid.- Entries are
folderorfile, with an opaqueidof up to 2,048 characters and aname. Files also carrysizeand may carrymediaType. Both kinds may carryupdatedAt, a Tablericonclass, and up to three displaytagsof up to 40 characters with an optionaltone. Tags describe an entry; they do not filter or grant anything. readtakes a fileidfromlistand returns a read stream. Report the file's media type in the stream descriptor.savetakes aparentfolderid, a single filename, amediaType, and the exactsize, and returns a write stream. It only creates files: when the name exists, fail with status409and the codeFILE_PROVIDER_NAME_CONFLICT(FILE_NAME_CONFLICT), from the Action or from the stream'swriteorstatus. Consumers ask for another name only on this code. Every other failure keeps its own code, even with status409, for example when storage is full. The completed write returns{ file: { id, name, size } }; the call that opens the stream has no file yet.
Implement a provider
Choose your own capability IDs and declare them in fileProvider. Reusing the
contract schemas is the simplest compatible choice:
import { defineCapabilities } from "@k2b/cloud";
import {
FileProviderListDataSchema,
FileProviderListInputSchema,
FileProviderReadInputSchema,
FileProviderSaveDataSchema,
FileProviderSaveInputSchema,
} from "@k2b/cloud/contracts";
import { ok } from "@k2b/stdlib";
import { z } from "zod";
const MAX_BYTES = 25 * 1024 * 1024;
export const archiveCapabilities = defineCapabilities({
protocolVersion: 2,
queries: {
"folder.list": {
title: "Browse archived documents",
description: "List one folder of archived documents the caller can read.",
input: FileProviderListInputSchema,
data: FileProviderListDataSchema,
openWorld: false,
run: async (input, context) => ok({ data: await listReadableFolder(context.accessSubject, input) }),
},
"document.read": {
title: "Read archived document",
description: "Open one archived document as a binary stream.",
input: FileProviderReadInputSchema,
data: z.object({ id: z.string() }).strict(),
openWorld: false,
stream: {
direction: "read",
maxBytes: MAX_BYTES,
read: async (stream, context) => openDocument(context.accessSubject, stream.id),
},
run: async ({ id }, context) => ok(await describeDocument(context.accessSubject, id)),
},
},
actions: {
"document.save": {
title: "Archive a new document",
description: "Create one new document in a writable folder; an existing name fails with FILE_NAME_CONFLICT.",
input: FileProviderSaveInputSchema,
data: FileProviderSaveDataSchema,
destructive: false,
openWorld: false,
idempotency: "required",
stream: {
direction: "write",
maxBytes: MAX_BYTES,
write: async (stream, body, context) => storeDocument(context.accessSubject, stream, body),
status: async (stream, context) => documentUploadStatus(context.accessSubject, stream),
abort: async (stream, context) => abortDocumentUpload(context.accessSubject, stream),
},
run: async (input, context) => ok(await reserveDocument(context.accessSubject, input, context.idempotencyKey)),
},
},
fileProvider: { list: "folder.list", read: "document.read", save: "document.save" },
});The undeclared functions stand for your own permission-aware service code.
describeDocument returns { data, stream } with the descriptor of the
document's current revision; reserveDocument returns { data: {}, stream }
for a durable upload reservation. When the name exists, reserveDocument and
storeDocument throw
{ code: FILE_PROVIDER_NAME_CONFLICT, message, status: 409 }. Follow
Binary streams for
descriptors, receipts, and recovery.
Omit save from fileProvider for a read-only provider. One application has
one provider; offer several roots as virtual folders instead.
Keep these rules in every call:
- Authorize each call with the caller's own access. Listing a folder is not a
grant, and an
idis not one either; a consumer may hold anidit can no longer use. Answer404or403as for any other resource. - List only entries the caller can open. Report
writablefor the folder from the caller's current permission;savestill checks it again. - Keep identifiers and cursors opaque. A page may be short or empty and still
continue while
nextis set; returnnext: nullon the last page. - Fail when storage cannot be reached, for example with
503. Never answer an outage with an empty or shorter page; it looks like lost files. - Bound
maxBytesby what you can serve. Consumers combine it with their own limit, and Core rejects larger transfers. - Make
saveidempotent per key: a retry with the same key returns the same transfer, never a second file and never a conflict with itself.
Understand compatibility
Your application checks the declaration when it starts. app.start() fails
when a named capability is missing or does not match its function, for
example a read Query without a read stream. Core runs the same check when it
reads your live manifest. If it fails there, Core ignores the declaration,
logs Ignored a file provider that does not match the file-provider contract
once per manifest, and keeps your other capabilities available.
A capability is compatible when:
- Kind and stream.
listandreadare Queries;saveis an Action that requires an idempotency key.readdeclares a read stream,savea write stream, andlistno stream. Stream sizes are not compared. - Input. Your input accepts everything the contract may send, with the same rules as the contact directory: string syntax stays yours, but required fields, numeric bounds, and closed objects must fit the contract.
- Result. Every result you can return satisfies the contract data schema. Extra fields and narrower types are fine, and literal values must lie within the contract's bounds.
Test a declaration with the same function Cloud uses:
import { compileCapabilityManifest } from "@k2b/cloud/capabilities/testing";
import { fileProviderIssues } from "@k2b/cloud/contracts";
const manifest = compileCapabilityManifest("archive", archiveCapabilities);
// An empty list means the declaration is valid.
const issues = fileProviderIssues(manifest);Each issue names the function, the localId it points to, and the same
code, path, and message as capabilityContractIssues.
Find providers
Every live application whose manifest in the capability catalog
(GET /api/capabilities/v1/catalog) has fileProvider is a provider. There is
no separate registration, route, or setting. The declaration names the local
IDs to call; invoke them through the ordinary capability client and stream
transfer.
chooseFiles() does this for you. It reads every catalog page once per page
load, while the browser is idle, and keeps the applications whose list and
read pass fileProviderIssues. If the list is not known yet when someone
clicks, the chooser opens at once and providers join below This device as
they arrive. A failed catalog read shows Try again instead of an empty
list.
Roll out a provider
fileProvider is optional. A manifest without it keeps its earlier shape and
hash, so nothing changes for applications that do not offer files.
Files declares fileProvider. Update Core before Files, and update every
other reader of the capability catalog once.
Before your own application declares fileProvider, update every reader of
the capability catalog to a release that
reads manifests from newer releases:
- Core;
- every other application built on
@k2b/cloud, because its global search reads the catalog for Commands, and so dolistCapabilityCatalog()andgetCapabilityCatalogApp(); - the
capabilitiesplugin of eachcldprofile, withcld plugins update capabilities.
A reader of cloud-v0.29.0 or earlier cannot read a manifest that carries
fileProvider. Core drops the application from its catalog, and the other
readers fail on the whole catalog page, so every application disappears for
them, not only the provider. See
Deprecations and migrations.
Readers that know fileProvider are tolerant in turn: a declaration with a
function they do not know is left out as a whole, and the application's other
capabilities stay available. Core logs the declaration with the entries it
left out.