storage keeps a file in the signed-in account under a key your app holds, and anyone holding the file’s url and that key can read it, with an account or without one. This page covers what put answers, the key, where the encryption runs, reading, listing and deleting, the limits, and what FKN keeps.
It is a different thing from the file systems on storage, which keep files by path for your app alone. An object has no path and no name: it is a url, a key and the bytes behind them, made for a file too large for a room message, for handing a file to someone with no account at all, and for an app’s own drive.
Five functions are the whole entry: available, put, get, list and delete. The rest of the block is types. Import from @fkn/lib/storage, or use the storage namespace on the root entry, and call storage.delete(url): delete is a reserved word, so a named import spells it { delete as remove }.
Seals the data in the broker and uploads it, then answers its url and the key that opens it: the
app's own key unchanged, or the one the broker minted. Resolves once the object is ready. Needs
a signed-in account, and counts the sealed size (28 bytes more per 1 MiB) against the account's
storage from the moment it starts until the object is deleted or the upload is aborted. The account
the call belongs to is the one this page is on when it is made, as for every storage call.
data is a Blob (a File included) or a ReadableStream<Uint8Array> with its size. An upload
survives the network dropping a part, not a reload: a reload starts the file again, and the upload
left behind stops counting within the hour.
Refused invalid for a stream without its size or one that delivers another, a file past the
service's part ceiling, or a key that is not 32 bytes base64url; denied with no account; quota;
too-many past the account's stored objects or this hour's uploads; account-changed.
https://cdn.fkn.app/<uuid> in production: anyone holding it can download the sealed bytes, and nothing more
url// 'https://cdn.fkn.app/' and a lowercase uuid, the address of the sealed bytes
conststored:storage.StoredObject& {
key:string;
}
stored.
key: string
key// the key that opens them, 43 characters of base64url, minted since none was passed
conststored:storage.StoredObject& {
key:string;
}
stored.
size: number
the file's own bytes; the account's storage counts 28 bytes more per 1 MiB
size// file.size, the bytes of the file itself
conststored:storage.StoredObject& {
key:string;
}
stored.
status: "uploading"|"ready"
uploading until every part has landed
status// 'ready'
conststored:storage.StoredObject& {
key:string;
}
stored.
created: number
created// epoch milliseconds
}
The url is https://cdn.fkn.app/ followed by a lowercase UUID the service mints, so nobody can choose or compute one, and nothing in it comes from the file. The url is not the secret: it serves the sealed bytes to anyone who asks, and only the key opens them. Keep the url and the key together, and share them the way you would share the file itself.
available() answers whether put, list and delete can run here: a broker that serves storage, and an account connected to your app. It answers false rather than rejecting. get needs no account and does not depend on it.
data is a Blob, a File included, or a ReadableStream<Uint8Array> with its size, the exact bytes it will deliver. The broker uploads it in parts of 16 MiB, three at a time, sends a part again when the network drops it, and calls onProgress as each part lands. signal aborts the upload and releases the storage it reserved:
app.ts
const
constcontroller:AbortController
controller=new
var AbortController:new () =>AbortController
The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.
Seals the data in the broker and uploads it, then answers its url and the key that opens it: the
app's own key unchanged, or the one the broker minted. Resolves once the object is ready. Needs
a signed-in account, and counts the sealed size (28 bytes more per 1 MiB) against the account's
storage from the moment it starts until the object is deleted or the upload is aborted. The account
the call belongs to is the one this page is on when it is made, as for every storage call.
data is a Blob (a File included) or a ReadableStream<Uint8Array> with its size. An upload
survives the network dropping a part, not a reload: a reload starts the file again, and the upload
left behind stops counting within the hour.
Refused invalid for a stream without its size or one that delivers another, a file past the
service's part ceiling, or a key that is not 32 bytes base64url; denied with no account; quota;
too-many past the account's stored objects or this hour's uploads; account-changed.
get('content-length')), // a stream needs its exact length
signal?: AbortSignal |undefined
aborts the upload and releases the storage it reserved
signal:
constcontroller:AbortController
controller.
AbortController.signal: AbortSignal
The signal read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.
A reload is the one thing an upload does not survive. The file starts again from its first byte, and the upload left behind stops counting once a sweep finds it idle for an hour.
The key is your app’s, by the same rule as a room key: 32 bytes written as unpadded base64url, 43 characters. Pass key to seal under one your app already holds, and put answers it unchanged. Leave it out and the broker mints a fresh one and answers that. Anything else is refused storage: the key is not 32 bytes base64url before the upload starts.
Seals the data in the broker and uploads it, then answers its url and the key that opens it: the
app's own key unchanged, or the one the broker minted. Resolves once the object is ready. Needs
a signed-in account, and counts the sealed size (28 bytes more per 1 MiB) against the account's
storage from the moment it starts until the object is deleted or the upload is aborted. The account
the call belongs to is the one this page is on when it is made, as for every storage call.
data is a Blob (a File included) or a ReadableStream<Uint8Array> with its size. An upload
survives the network dropping a part, not a reload: a reload starts the file again, and the upload
left behind stops counting within the hour.
Refused invalid for a stream without its size or one that delivers another, a file past the
service's part ceiling, or a key that is not 32 bytes base64url; denied with no account; quota;
too-many past the account's stored objects or this hour's uploads; account-changed.
Seals the data in the broker and uploads it, then answers its url and the key that opens it: the
app's own key unchanged, or the one the broker minted. Resolves once the object is ready. Needs
a signed-in account, and counts the sealed size (28 bytes more per 1 MiB) against the account's
storage from the moment it starts until the object is deleted or the upload is aborted. The account
the call belongs to is the one this page is on when it is made, as for every storage call.
data is a Blob (a File included) or a ReadableStream<Uint8Array> with its size. An upload
survives the network dropping a part, not a reload: a reload starts the file again, and the upload
left behind stops counting within the hour.
Refused invalid for a stream without its size or one that delivers another, a file past the
service's part ceiling, or a key that is not 32 bytes base64url; denied with no account; quota;
too-many past the account's stored objects or this hour's uploads; account-changed.
the key that opens the object, 32 bytes as canonical unpadded base64url (43 characters), minted
when absent. It never reaches FKN's servers and never appears in the url: whoever holds the url
and the key reads the object.
key:
constappKey:string
appKey }) // the app's own key
constkept:storage.StoredObject& {
key:string;
}
kept.
key: string
key===
constappKey:string
appKey// true
One key can seal any number of objects. Each object is sealed under a key derived from yours and from the object’s id, so two objects never share a content key, and reusing an app key across files is safe. Whoever holds a key opens every object sealed under it, though, so an app that hands single files to other people passes no key and lets each file get its own.
The account’s own encryption key, the one encryption covers, plays no part. Nothing here reads it, so these calls never show the unlock card, and a key reset on the account leaves every object readable with the key that sealed it.
Sealing and opening run in the broker, the hidden fkn.app frame @fkn/lib mounts, as they do for a room. Your page hands the broker the file and the key. The broker seals the file in 1 MiB records and uploads only ciphertext, and on a read it fetches ciphertext and hands your page plaintext.
FKN’s servers receive the sealed bytes and their size, and never the key or a byte of the file. The url never carries the key either. The sealed format is versioned and fixed, so an object stored today opens with its key for as long as it exists.
Every call needs that frame, get included, even though get needs no account. In Node there is no broker, so available() answers false and every call with valid arguments is refused storage: storage is unavailable. Your page needs no connect-src entry for cdn.fkn.app in its own Content Security Policy, because every request to it leaves the fkn.app frame rather than your page.
Opening the url directly downloads the sealed bytes as an application/octet-stream attachment. The address never shows them as a page and asks every browser not to cache them.
Opens an object any app stored, with no account: the broker reads its size from the first ranged
answer and fetches the sealed bytes by range. Refused invalid for a url that names no stored object
or a key that is not 32 bytes base64url, not-found for an object that was never made, was deleted
or is still uploading (one answer for all of them). A wrong key shows on the first read, as
integrity. A broker replaced while the object is held is asked to open it again on the next read.
get(
consturl:string
url,
constkey:string
key) // no account needed
constblob:storage.StoredBlob
blob.
size: number
the plaintext bytes of this blob, a slice's own length
size// the bytes of the file itself
const
constwhole:ArrayBuffer
whole=await
constblob:storage.StoredBlob
blob.
arrayBuffer: () =>Promise<ArrayBuffer>
arrayBuffer() // every record checked as it arrives
errors with integrity on the first record that does not open under the key
stream()) // a stream, for anything that takes one
slice takes Blob.slice’s arguments, negative ones counted from the end, and answers the same shape. Every read is by range, and each 1 MiB record is opened and checked before it is handed over, so a slice near the end of a large file fetches only that end. A read that loses its connection picks up again at the next whole record, and a broker replaced while your page holds a blob is asked to open the object again on the next read.
get resolves before any record is opened, so a wrong key shows on the first read, which errors storage: the object does not match its key. Bytes that are not this object’s give the same answer, so your page never receives a byte that did not open under the key. An object that was never made, was deleted or is still uploading is one answer, storage: no object at this url.
The url is checked before any request. It must be exactly the service’s address and a lowercase UUID, with no query, no trailing slash and nothing after the id, and anything else is refused storage: the url does not name a stored object. Keep each url exactly as put or list answered it.
This app's objects, newest first, uploads in progress included: no key, which the service never
holds. limit is 1 to 1,000 (1,000 by default), and cursor is the one a previous page answered;
invalid otherwise.
This app's objects, newest first, uploads in progress included: no key, which the service never
holds. limit is 1 to 1,000 (1,000 by default), and cursor is the one a previous page answered;
invalid otherwise.
list({
limit?: number |undefined
limit: 100,
cursor?: string |undefined
cursor:
let page:storage.StoredPage
page.
cursor?: string
cursor }) // the next 100
constall:storage.StoredObject[]
all.
Array<StoredObject>.push(...items: storage.StoredObject[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@param ― items New elements to add to the array.
push(...
let page:storage.StoredPage
page.
objects: storage.StoredObject[]
objects)
}
constall:storage.StoredObject[]
all[0]?.
status: "uploading"|"ready"
uploading until every part has landed
status// 'ready', or 'uploading' for an upload still running
limit is 1 to 1,000, and 1,000 when absent. cursor is present only while more objects remain, so a page without one is the last, and a cursor not in the shape list answers is refused storage: the cursor is not one list answered. Each entry carries the url, size, status and creation time, and never a key, which the service never holds.
An app sees only the objects it stored, the same app once it is verified. The person sees the account’s total on the Usage tab of their fkn.app settings, as Public files with their bytes and count, and the account’s data export lists every object with its url, its sealed size, the app that stored it, its date and whether it is ready.
Names, types and folders are not stored anywhere. Keep them in your app’s own index, in cloud.fs for example, beside each url and its key. list is how that index is rebuilt if it is lost, every object but its key.
delete is the only way an object ends. Nothing expires, and an object stays, counted in the account’s storage, until your app deletes it or the account is deleted:
Deletes an object by its url: every later get of it answers not-found, and an upload in progress
is aborted and stops counting. Only the app that stored it (the same app once it is verified)
deletes it, denied otherwise. An object already gone resolves.
Opens an object any app stored, with no account: the broker reads its size from the first ranged
answer and fetches the sealed bytes by range. Refused invalid for a url that names no stored object
or a key that is not 32 bytes base64url, not-found for an object that was never made, was deleted
or is still uploading (one answer for all of them). A wrong key shows on the first read, as
integrity. A broker replaced while the object is held is asked to open it again on the next read.
Once delete resolves, every new read answers not-found: a get, and a fresh stream() or arrayBuffer() on a blob your page already holds. A read already streaming when the delete lands finishes, unless it has to reconnect, which then answers not-found. A copy someone already downloaded stays with them, so a delete stops new reads and takes back nothing already read.
Only the app that stored an object deletes it, the same app once it is verified, and another app of the account is refused storage: only the app that stored the object can delete it. delete resolves for an object already gone, and for a url this account does not hold, which it leaves as it is. Deleting an upload still in progress aborts it, and its storage stops counting at once.
A key cannot be taken back. To stop people reading a file you shared, delete its object. To keep the file for yourself, store it again under a new key and delete the old object.
An app that is revoked, or that stops running, leaves its objects in place, served and counted until the account is deleted, the same rule as its files. Deleting the account deletes every object it holds.
An account holds at most 10,000 objects on a free or a premium plan, uploads in progress included, counted apart from its cloud.fs files. One object is at most 167,772,160,000 bytes.
An account starts at most 1,000 uploads an hour on a free plan and 5,000 on premium. A put refused for its arguments or its account, or by the object cap, the quota or this hourly cap as it starts, does not count. Any upload past those checks counts, even if the store then fails, the upload is aborted, or it is refused when it completes.
The account’s storage counts each object at its sealed size, 28 bytes more than the file for every 1 MiB, against the same quota as its files and mailboxes, from the moment the upload starts until the object is deleted. cloud.fs.quota() includes it. Reads are not counted, and they do not touch the daily volume cloud.quota() reports.
FKN keeps the sealed bytes, their size, the app that stored them and when. It never holds the key, the file’s name or type, or a byte of its content. Whoever downloads an object reaches Cloudflare’s delivery network, which sees their IP address, and FKN does not count downloads.
The first read did not open under the key: it is not the key this url was stored with, or the bytes are not this object’s. Read with the key put answered for this url.
put, list or delete ran with no account connected to your app. Call connect() first, and check storage.available() before offering an upload.
Every other message has its row on every error. They share the storage: prefix with the file system errors, so match on code, and how to match one is on handling errors.