Skip to content

Object storage

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.

export type StorageErrorCode =
| 'invalid' | 'not-found' | 'integrity' | 'denied'
| 'quota' | 'too-many' | 'account-changed' | 'unavailable'
export type StorageError = Error & { code: StorageErrorCode }
export type StoredObject = { url: string, size: number, status: 'uploading' | 'ready', created: number }
export type StoredPage = { objects: StoredObject[], cursor?: string }
export type StorageProgress = { loaded: number, total: number }
export type StoragePutOptions = {
key?: string
size?: number
signal?: AbortSignal
onProgress?: (progress: StorageProgress) => void
}
export type StoredBlob = {
size: number
stream: () => ReadableStream<Uint8Array>
arrayBuffer: () => Promise<ArrayBuffer>
slice: (start?: number, end?: number) => StoredBlob
}
export const available: () => Promise<boolean>
export const put: (data: Blob | ReadableStream<Uint8Array>, options?: StoragePutOptions) => Promise<StoredObject & { key: string }>
export const get: (url: string, key: string, options?: { signal?: AbortSignal }) => Promise<StoredBlob>
export const list: (options?: { limit?: number, cursor?: string }) => Promise<StoredPage>
declare const remove: (url: string) => Promise<void>
export { remove as delete }

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 }.

put seals the file, uploads it and resolves once the object is ready, with its url and the key that opens it:

app.ts
if (await
(alias) namespace storage
import storage
storage
.
storage_d_exports.available(): Promise<boolean>
export storage_d_exports.available

Whether put, list and delete can run here now. get needs no account and does not depend on this. Answers rather than rejecting.

available
()) {
const
const stored: storage.StoredObject & {
key: string;
}
stored
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.put(data: Blob | ReadableStream<Uint8Array>, options?: storage.StoragePutOptions): Promise<storage.StoredObject & {
key: string;
}>
export storage_d_exports.put

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.

put
(
const file: File
file
) // resolves once every part has landed
const stored: storage.StoredObject & {
key: string;
}
stored
.
url: string

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
const stored: storage.StoredObject & {
key: string;
}
stored
.
key: string
key
// the key that opens them, 43 characters of base64url, minted since none was passed
const stored: 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
const stored: storage.StoredObject & {
key: string;
}
stored
.
status: "uploading" | "ready"

uploading until every part has landed

status
// 'ready'
const stored: 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
const controller: 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.

MDN Reference

AbortController
()
const
const stored: storage.StoredObject & {
key: string;
}
stored
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.put(data: Blob | ReadableStream<Uint8Array>, options?: storage.StoragePutOptions): Promise<storage.StoredObject & {
key: string;
}>
export storage_d_exports.put

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.

put
(
const response: Response
response
.
Body.body: ReadableStream<Uint8Array<ArrayBuffer>> | null
body
!, {
size?: number | undefined

required for a ReadableStream: the bytes it will deliver, exactly

size
:
var Number: NumberConstructor
(value?: any) => number

An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.

Number
(
const response: Response
response
.
Response.headers: Headers

The headers read-only property of the with the response.

MDN Reference

headers
.
Headers.get(name: string): string | null

The get() method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name.

MDN Reference

get
('content-length')), // a stream needs its exact length
signal?: AbortSignal | undefined

aborts the upload and releases the storage it reserved

signal
:
const controller: 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.

MDN Reference

signal
, // abort() rejects the put and releases what it reserved
onProgress?: ((progress: storage.StorageProgress) => void) | undefined

called as each part lands

onProgress
: ({
loaded: number
loaded
,
total: number
total
}) => {
const bar: HTMLProgressElement
bar
.
HTMLProgressElement.value: number

The value property of the HTMLProgressElement interface represents the current progress of the progress element.

MDN Reference

value
=
loaded: number
loaded
/
total: number
total
}, // once per part
})

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.

app.ts
const
const single: storage.StoredObject & {
key: string;
}
single
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.put(data: Blob | ReadableStream<Uint8Array>, options?: storage.StoragePutOptions): Promise<storage.StoredObject & {
key: string;
}>
export storage_d_exports.put

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.

put
(
const file: File
file
) // a fresh key that opens this object alone
const
const kept: storage.StoredObject & {
key: string;
}
kept
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.put(data: Blob | ReadableStream<Uint8Array>, options?: storage.StoragePutOptions): Promise<storage.StoredObject & {
key: string;
}>
export storage_d_exports.put

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.

put
(
const file: File
file
, {
key?: string | undefined

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
:
const appKey: string
appKey
}) // the app's own key
const kept: storage.StoredObject & {
key: string;
}
kept
.
key: string
key
===
const appKey: 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.

get takes the url and the key, asks for the object’s first byte to learn its size, and resolves with a blob shaped like a Blob:

app.ts
const
const blob: storage.StoredBlob
blob
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.get(url: string, key: string, options?: {
signal?: AbortSignal;
}): Promise<storage.StoredBlob>
export storage_d_exports.get

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
(
const url: string
url
,
const key: string
key
) // no account needed
const blob: storage.StoredBlob
blob
.
size: number

the plaintext bytes of this blob, a slice's own length

size
// the bytes of the file itself
const
const whole: ArrayBuffer
whole
= await
const blob: storage.StoredBlob
blob
.
arrayBuffer: () => Promise<ArrayBuffer>
arrayBuffer
() // every record checked as it arrives
const
const tail: ArrayBuffer
tail
= await
const blob: storage.StoredBlob
blob
.
slice: (start?: number, end?: number) => storage.StoredBlob

Blob.slice's arguments, negative ones counted from the end, and the same shape back

slice
(-1_048_576).
arrayBuffer: () => Promise<ArrayBuffer>
arrayBuffer
() // the last 1 MiB, fetching only the end
const
const response: Response
response
= new
var Response: new (body?: BodyInit | null, init?: ResponseInit) => Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
(
const blob: storage.StoredBlob
blob
.
stream: () => ReadableStream<Uint8Array>

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.

list answers your app’s objects a page at a time, newest first, uploads in progress included:

app.ts
let
let page: storage.StoredPage
page
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.list(options?: {
limit?: number;
cursor?: string;
}): Promise<storage.StoredPage>
export storage_d_exports.list

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 }) // the newest 100
const
const all: storage.StoredObject[]
all
= [...
let page: storage.StoredPage
page
.
objects: storage.StoredObject[]
objects
]
while (
let page: storage.StoredPage
page
.
cursor?: string | undefined
cursor
) {
let page: storage.StoredPage
page
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.list(options?: {
limit?: number;
cursor?: string;
}): Promise<storage.StoredPage>
export storage_d_exports.list

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
const all: 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
)
}
const all: 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:

app.ts
await
(alias) namespace storage
import storage
storage
.
storage_d_exports.delete(url: string): Promise<void>
export storage_d_exports.delete

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.

delete
(
const url: string
url
) // resolves for an object already gone too
const
const answer: string
answer
= await
(alias) namespace storage
import storage
storage
.
storage_d_exports.get(url: string, key: string, options?: {
signal?: AbortSignal;
}): Promise<storage.StoredBlob>
export storage_d_exports.get

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
(
const url: string
url
,
const key: string
key
).
Promise<StoredBlob>.then<string, "invalid" | "not-found" | "integrity" | "denied" | "quota" | "too-many" | "account-changed" | "unavailable">(onfulfilled?: ((value: storage.StoredBlob) => string | PromiseLike<string>) | null | undefined, onrejected?: ((reason: any) => "invalid" | "not-found" | "integrity" | "denied" | "quota" | "too-many" | "account-changed" | "unavailable" | PromiseLike<"invalid" | "not-found" | "integrity" | "denied" | "quota" | "too-many" | "account-changed" | "unavailable">) | null | undefined): Promise<...>

Attaches callbacks for the resolution and/or rejection of the Promise.

@param ― onfulfilled The callback to execute when the Promise is resolved.

@param ― onrejected The callback to execute when the Promise is rejected.

@returns ― A Promise for the completion of which ever callback is executed.

then
(() => 'ready', (
error: storage.StorageError
error
:
(alias) namespace storage
import storage
storage
.
type storage_d_exports.StorageError = Error & {
code: storage.StorageErrorCode;
}
export storage_d_exports.StorageError

Thrown by every member of this namespace except on an aborted signal. Match on code, never on the message.

StorageError
) =>
error: storage.StorageError
error
.
code: storage.StorageErrorCode
code
) // 'not-found'

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.

Every number is on limits and timeouts.

The five rows a reader of this page meets most, each linked to its row on every error:

MessageWhat happened
storage: the object does not match its keyThe 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.
storage: no object at this urlThe object was never made, was deleted, or is still uploading. Treat the file as gone unless your app knows the upload is running.
storage: storage quota exceededThe account’s storage has no room for the sealed size. Delete objects or files the app no longer needs.
storage: too many objects stored this hour, try again laterThe account started its hourly allowance of uploads. Queue the file and send it once the hour is over.
storage: storing needs an accountput, 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.