Skip to content

Uploads and link previews

Blok needs somewhere to put uploaded files, and something to read a pasted link's title and picture. Neither is something a browser is allowed to do on its own.

There are three ways to cover them. The first runs nothing at all, so start there. Find the heading that describes you and ignore the other two.

First, the honest limit on link previews

Link previews work for roughly 70% of the sites people paste. X, Instagram, LinkedIn and many news sites serve no preview data to anything that is not a browser, and no service can read what a site does not send.

When a preview cannot be read, the block falls back to a plain link showing the domain. It is never an error, and nothing about the paste breaks.

Your situationRoute in your appRuns the service?
You already have storageNoneNo
Your app runs ASP.NET CoreYesYes
You run your own backendYesYes
You are on Vercel or NetlifyYesYes

Read this before you deploy

Documents stay yours, and the service keeps only a working copy

Saving and loading documents stays a small endpoint in your own app, the same place your permission check already lives. That endpoint remains the record. It is what lands in your backups, and it is what you clean when someone asks you to delete their data.

Live collaboration adds one moving part. While people edit together, the service keeps a working copy of the open document, in storage you point it at. Every few seconds it writes what people type back to your endpoint.

Losing that working copy costs at most the few seconds of changes not yet written back. The next open starts fresh from your endpoint.

Those same few seconds are a lag in the other direction too. Your record trails what the people in the document are looking at. So an export, a report or another service reading your database sees the document as of the last write-back. It does not see the keystroke someone just typed.

A write-back your endpoint refuses is retried with a growing delay rather than dropped. A document whose last write-back has not landed stays loaded until it does, even after everybody has left it. So a short outage at your endpoint costs time, not edits.

That cadence belongs to the working copy alone. Register an operation journal and the document keeps no working copy at all. The entry below on retry-safe outside edits says how.

With a journal, an edit becomes durable in the journal. The record at your endpoint is then refreshed from a published checkpoint. That refresh happens on eviction and on drain, not on a timer. The lag above is then no longer a few seconds, and the entry on the rollback boundary prices it.

With live collaboration on, your app stops saving the document itself

You cannot combine the editor's own persistence option with collaboration. That option is the pair of load and save callbacks a single-player editor uses to talk to your endpoint.

The editor refuses the pair the moment it is created, and it says which one to drop. Otherwise the document would have two owners writing the same record from two places.

Nothing is lost in the trade. The service loads the document from your document endpoint and writes it back to the same route. So it is your endpoint either way, called from the service instead of from the browser.

The same goes for saving by hand. onChange and onSave keep firing while people edit together, including for the edits other people make. That is deliberate, so a form bound to the editor stays current.

Do not write what they hand you back into your record. That is the service's job now, and a second writer only fights it.

Read it the other way round too. The persistence option writes the whole document JSON on a debounce. When saves pile up, only the newest payload goes out.

Every save carries the whole document, so nothing anybody typed is lost: the newest payload carries it. What is never recorded anywhere is the document states in between. It is the compatibility route for a build with no service, not a record of what happened.

Where durable saving is the point, collaboration is the path. The entry on the save state below says what saved means there.

The working copy must not be publicly readable

While people edit together, the working copy lives in the folder you name with --collab-dir. It can also live under the S3 prefix you set with --collab-s3-prefix. It holds document content, so it must not be publicly readable.

Uploaded files are meant to be served to anyone with the link. The working copy is meant for the service alone. That is also why the service refuses to put it inside the uploads directory: everything there is served.

Two things about the folder --collab-dir names.

  • A file the service cannot read is renamed beside itself with an .unreadable- suffix.
    • The document is then loaded fresh from your endpoint, so one damaged file never wedges a document.
  • Temporary files left behind by a writer that died mid-write are deleted when the service starts.

Wherever the working copy lives, one written in a format this service does not read fails the open instead of being guessed at. The log names the format, and the file has to be removed before that document opens again.

Retry-safe outside edits need an operation journal

Two promises about POST /sync/{doc}/edit hold only where the service has an operation journal.

  • Retrying the same Blok-Idempotency-Key returns the first result instead of applying the edit a second time.
  • Reusing that key for different work is refused with 409.

The working copy is not a journal. Neither the folder named by --collab-dir nor the prefix named by --collab-s3-prefix keeps the history of what it holds. There are two ways to get one.

  • On the standalone host, add --collab-journal, or set BLOK_COLLAB_JOURNAL=true.
    • It keeps the built-in journal on local disk, beside the working copy under --collab-dir.
    • It needs --collab. It is refused with --collab-s3-prefix, because a second instance sharing the bucket would not see a journal on this machine's disk.
    • So it serves one instance. In-process, the same switch is options.CollabJournal.
  • For more than one instance, write your own store.
    • Inside your own ASP.NET Core app, implement ICollabOperationStore over storage you already trust with transactions.
    • Register it with AddBlokServer(...).UseCollabOperationStore<YourStore>().
    • A store you register replaces the built-in one.

Either way, the first open of each document adopts the working copy it had as the journal's starting point, then deletes it. Nothing the working copy held is lost.

Document purge needs one more interface. Your store must also implement ICollabOperationPurgeStore. Otherwise PurgeDocumentAsync throws NotSupportedException before it removes anything. The built-in journal already does.

With no journal the service is working-copy-only. An outside edit then behaves like any other request you can retry. It is applied, answered 204, and applied again if you send it twice.

With a journal, every accepted change is committed to your journal before anybody is told about it. So a change that reached a tab is a change that survived the process dying. And an edit the journal cannot commit is answered 503 with nothing relayed, instead of a 204 for a save that did not happen.

What the service asks of a store you write is strict.

  • It needs one live writer per document.
  • It needs a read that sees every earlier commit.
  • It must be durable before the call returns.
  • The duplicate check and the sequence assignment happen in one step.
  • Any failure must be thrown rather than swallowed.

The server package's README states the whole list beside a working registration.

Turning the operation journal off is not a rollback

Registering an operation store is close to one-way, per document. A journal-backed document keeps no working copy at all. The whole-JSON record your endpoint holds is refreshed by three things only: a published checkpoint, an eviction, and a drain. It is never refreshed once per edit window.

Read that as a ceiling, not a promise. The check below is what tells you how fresh the record actually is.

Between those moments the journal is ahead of everything else, so the last edits the service acknowledged sit nowhere a build without your store can read. Unregister the store, or go back to a binary that never had it, and the document lands on whatever else it has.

  • A document that has been journal-backed from the start has no working copy.
    • It is loaded fresh from your endpoint, and it comes back as the last record that endpoint accepted.
    • Every operation acknowledged since then is still in your journal, and nothing serves it.
  • The first open under the journal adopts a document's working copy as its starting point, then deletes it.
  • A document that ran under --collab-dir or --collab-s3-prefix and has not been opened since you registered the store still holds the working copy it had that day.
    • A working copy with anything in it wins the open, and your endpoint is never consulted.
    • That document comes back as it was on the day you switched, with no error and nothing in the log to say so.

Blok does not keep a second whole-document copy beside the journal to make the switch back instant. The journal is the record, and the JSON is a projection of it.

Run this before you roll back.

  • Drain the service, which flushes every open room's record.
    • The standalone host drains on a graceful stop.
    • An in-process app calls ICollabRoomManager.DrainAsync.
  • Then check, per document, that the last write-back your endpoint accepted carries a Blok-Doc-Sequence equal to your journal head's durable-through on the same Blok-Doc-Lineage.
    • Anything short of that is what the rollback drops.
  • For every document that comes up short, open it once on the new build.
    • A read-only join is enough.
    • Then run the drain and the check again.
    • Loading a journal-backed room marks its record owed, and a document whose process crashed with one owed had no room for the drain to flush.
  • Only then start the old build, and keep clients held off while you do.

A room hydrated from a working copy carries no document version yet. Its first write-back goes out with no Blok-Doc-Version, so your endpoint has nothing to answer 409 on. One character typed by one user before the reset lands writes the day-of-switch document over the record you just verified.

Finish with one POST /sync/{doc}/reset for every document that had a working copy before you registered the store. That empties the copy, and the next open seeds from your endpoint. Let clients back in after the last reset has returned, not before.

The way forward is one-way too. Register the store again and the journal wins the open, so whatever was typed while the old build was serving is not in it.

Changing a document from outside: two calls, and when to use which

While a document is open for live collaboration, the working copy wins. A change written straight into your own records will be overwritten the next time the service writes the document back. So there are two ways in from the outside, and both take a write pass for that document.

To change a few blocks, call POST /sync/{doc}/edit with a list of what to insert, update or remove. That fits a status flipped by a workflow, or a field filled in by a job.

  • The change lands in the document.
  • It appears in everybody's tab within moments.
  • It gets written back to your records like any other edit.

The whole request is one step: if any part of it is refused, nothing is applied. Every edit also needs a Blok-Idempotency-Key header with 1 to 128 printable ASCII characters.

  • With an operation journal, retrying the same key with the same request returns the first result without applying it again.
  • Reusing that key for different work returns 409.
  • A 204 follows the durable commit.
    • It includes Blok-Doc-Lineage and Blok-Doc-Sequence response headers.
  • If that commit cannot finish, the service returns 503 and does not relay the change.

A working-copy-only service does not deduplicate the key, and it never answers 409 for reusing one. Retrying it there has ordinary retry behavior, and its 204 still starts the existing write-back retry path. Either way, your own records trail by the write-back delay.

An edit can also be guarded. Send If-Match: "<lineage>:<sequence>", written exactly as those two response headers print them. The edit then applies only while the document is still at that head.

  • A different head returns 412 and applies nothing. The 412 carries the current Blok-Doc-Lineage and Blok-Doc-Sequence, so you can retry the same key with a fresh tag.
  • A key that is already committed still returns its first result, even when its tag is now stale.
  • A working-copy-only service has no head to check, so any If-Match returns 428 and applies nothing.
  • A list, *, a weak tag or any other shape returns 400.

To read before you edit, call GET /sync/{doc}/state. It returns the live document as JSON, edits made a moment ago included, and it needs only a read pass for that document. With an operation journal it also sends Blok-Doc-Lineage, Blok-Doc-Sequence and ETag: "<lineage>:<sequence>" for the exact head it reflects. Send that ETag back as If-Match, and the edit applies only if nothing changed in between.

Plan for two refusals. Both return 422 and apply nothing.

  • Placing a block under a parent that has no children list.
  • An after the parent's children list does not hold.

Each answer names the step and the block:

  • collab: op 0: block "p1" has no children list, so nothing can be placed under it.
  • collab: op 0: block "b7" is not in the document order, so nothing can be placed after it.

Inserts apply one after another against the document as it stands. So two inserts naming the same after land in reverse order. To append in order, chain them, each naming the one before it.

To replace a document wholesale, for a migration or a support fix, save your version to your own records first. Then call POST /sync/{doc}/reset.

  • The service throws its working copy away.
  • It loads the document fresh from your endpoint.
  • Then it tells every open tab to drop what it had and pick up the new version.

Nobody has to close anything either way. Editing a document nobody has open works too, because the service loads it for the occasion.

One more thing closes the gap between your save and the reset. Once your endpoint answers with a version, every write-back carries the last one it gave in a Blok-Doc-Version header. That header is your optimistic-concurrency handle.

Bump the version on your own save, and answer a stale PUT with 409. The service treats it as a failed write-back and retries. It stays refused until the reset loads your version. So a write already on the wire when you save cannot land over it.

Access, trash and permanent deletion belong to your app

Your host owns grants, trash, restore, retention and permanent deletion. Blok supplies recheck, purge and offline-scope operations, but no access-settings UI.

For revocation, this C# is host pseudocode. Inject ICollabRoomManager as rooms and ICollabDocumentPurger as purger. Commit the ACL before asking Blok to recheck live members.

  • await host.CommitRevocationAsync(documentId, userId);
  • await rooms.RecheckAccessAsync(documentId);

A revoked tab closes with 4403 forbidden. Allowed peers remain connected and can keep writing. Make new admissions read the committed ACL too. Call RecheckAccessAsync on each server instance serving the document. Do not use reset for an ACL change.

Trash keeps the document ID, body, working set and journal for your retention period. Restore changes only host state and access. Neither action calls purge.

For permanent deletion, this C# is host pseudocode. Commit a durable host tombstone before purging sidecar state.

  • await host.CommitPermanentTombstoneAsync(documentId);
  • var result = await purger.PurgeDocumentAsync(documentId, token => host.CanPurgeAsync(operatorUser, documentId, token));

Host authorization and consumer GET/PUT must deny the tombstoned ID after the commit, even with a journal. DocumentOpenElsewhere means another instance holds the document, so retry after quiescing it. A partial failure keeps this process barred; keep the host tombstone and retry the purge. After your policy checks, remove the canonical page record and parent pointer separately. Never reuse an opaque page ID.

Without a journal, quiesce every sidecar instance and make consumer GET/PUT durably refuse the tombstoned ID. The in-process gate alone cannot protect another instance or a restart. A journal tombstone blocks re-seeding even if the consumer GET still holds stale content.

For logout or account switch, this TypeScript is host pseudocode. Import inspectOfflineScope and forgetOfflineScope from @bloklabs/core. showDiscardWarning and closeEditorsAndSwitchScope are functions in your app.

  • const report = await inspectOfflineScope(oldScope);
  • showDiscardWarning(report);
  • await closeEditorsAndSwitchScope(oldScope);
  • await forgetOfflineScope(oldScope, { discardPending: true });

Inspect the pending outbox, adoptable updates and quarantine before explicitly discarding the whole offline scope. Close or switch every active tab first, or it may recreate that scope. A blocked or failed delete is not success. An already-offline device cannot be remotely erased until it reconnects.

Deleting sidecar data is not physical erasure from backups, replicas or S3 versions. Your host retention policy must handle those copies.

Your document endpoint keeps its own login check

The service calls your document endpoint in two cases. It loads a document the first time someone opens it, and it writes changes back while people edit.

If that endpoint requires login, and it should, put the header value it already expects into the BLOK_DOC_ENDPOINT_AUTH environment variable, Bearer and all. The service sends it verbatim on every call. Your route, your auth scheme, nothing new to build.

One rule on the value: it must be a single line. A carriage return or newline in it stops the service from starting, and it says so. The usual cause is a trailing newline from echo or from a file. A header carrying one would be dropped from every call, with nothing to see anywhere.

The same value can be passed as --doc-endpoint-auth instead, but the service warns against that: a flag puts the credential in the machine's process list.

A brand-new document must answer with nothing, not 404

When the first person opens a document, the service asks your document endpoint for it. For a document you have never saved, answer 200 with null (or { "data": null }). That means "nothing yet", and the editor starts empty.

A 404, a 5xx or an unreachable endpoint is treated as a failure, not as "new". The editor shows a reconnecting notice and nobody can start editing.

Seeding an empty document over a record that merely failed to load would later overwrite it. It also means a mistyped --doc-endpoint path fails loudly at the first open, instead of quietly emptying your documents.

Rich text reaches your endpoint as segments, not HTML

The service writes rich text fields to your document endpoint as segments. GET /sync/{doc}/state answers the same way. Before, data.text was an HTML string such as a <b>b</b>. Now it is a list such as [{ "text": "a " }, { "text": "b", "marks": { "bold": true } }].

  • Your endpoint must accept segments when the service writes a document back.
  • It may keep serving HTML. The service still accepts HTML from your endpoint and from POST /sync/{doc}/edit, and writes segments back.
  • The built-in paragraph, header, quote, toggle and list text fields are always rich.

Your own tools' rich fields need listing. On the standalone host, pass --rich-text-fields as JSON, such as {"callout":["title"]}, or set BLOK_RICH_TEXT_FIELDS. In-process, the same list is options.RichTextFields. List exactly what each tool's richTextFields declares on the client.

Upgrade the service and the editor together. A document stored by an older service is moved to the new format the first time it opens. An older editor refuses the new service, and a new editor refuses an older one.

Serve uploaded files from a different hostname than your app

A file someone uploads is served by whatever hostname you put it on, and a browser trusts a page by its hostname. So if uploads are served from the same hostname as your app, an uploaded file can act as if it were one of your own pages. It can read the signed-in user's session and call your API as them.

The service sends Content-Disposition: attachment and X-Content-Type-Options: nosniff, so a browser downloads such a file instead of running it. But the only complete answer is to put uploads on a different hostname from your app, such as uploads.myapp.com or an S3 bucket's own address.

The hostname serving your uploads has to answer CORS

Five things in the editor read an uploaded file back after it is stored.

  • The audio player fetches the track to draw its waveform.
  • The image block fetches a GIF before turning it into a video, and an SVG to read its real size.
  • The file block fetches a document to show a preview.
  • The image download button fetches the picture so the browser saves it rather than opening it.

Once uploads are served from their own hostname, every one of those is a cross-origin request. The browser refuses it unless the response carries Access-Control-Allow-Origin for your app.

Each of them is written to give up quietly rather than break the block. So you get no error.

  • The player has no waveform.
  • The GIF stays a still image.
  • The SVG is the wrong size.
  • The preview never appears.
  • The download opens the file instead of saving it.

Nothing in Blok reports why. Allow your app's origin on the bucket or hostname the files are served from.

The S3 support has not met a real bucket yet

Uploading to S3-compatible storage is built and covered by tests. But those tests check the request against our own reading of the specification. It has never been run against a real bucket, at AWS or anywhere else. Try it on a throwaway bucket before you rely on it.

If the storage refuses a request, the service passes the storage's own explanation through. The message you see is the one that endpoint sent.

A browser calling the service directly needs --allow-origin

Every request that carries an Origin must match an address listed with --allow-origin, in every auth mode. In none and proxy modes, an originless request marked Sec-Fetch-Site: cross-site is also refused. A genuine server-to-server backend request that carries neither header still works.

Ticket mode always requires an allowed Origin. Preflight requests follow the same allowlist.

Remote uploads accept JSON requests only

POST /upload-by-url requires Content-Type: application/json. Parameters such as charset=utf-8 are allowed. Other media types, including application/problem+json, receive 415 before the service fetches the URL.

Behind your own app, the rate limit is one allowance for everybody

The service limits how often a caller may make it fetch a URL.

  • When --rate-limit is omitted, ticket mode allows 60 requests a minute per caller.
  • Otherwise the limit is 0, which turns it off.
  • Any explicit value must be zero or greater.

The service tells callers apart by the address the request came from. When your app forwards every request, every request arrives from the same address. So the whole deployment shares one allowance, and one busy user can use it up.

Limiting per user belongs in your own app, on the forwarding route, where you already know who is signed in. This does not apply on the third path, where the pass names the user and each user is counted separately.

A pass names a user, and a collaboration pass also names its document

A pass always says who is holding it and whether they may write. For uploads and link previews that is all the service reads.

Those routes do not confine the holder to one document. So a pass handed to someone who opened a single page works for every upload and every preview that page can make, for as long as the pass lives.

Live collaboration is stricter.

  • A collaboration pass also names the document it may open, and the service checks that name at the door.
    • A pass that names no document, or a different one, is turned away.
  • It has to name its user as well, because the connection cap and the per-minute allowance both key on that name.
    • A collaboration pass with an empty user is refused, not pooled with every other anonymous one.

Keep the life short either way. Hand a pass out only from a route that has already checked who is signed in. Keep the per-document check for uploads in your own app, where you know which document is open.

Give a collaboration pass about 30 minutes

An upload pass is checked on every request, so a few minutes of life is right for it. A collaboration pass is checked once, when the connection opens, and the connection then outlives it. So the pass has to be alive at the moments the editor connects and reconnects.

Around 30 minutes is the balance.

  • It is short enough that a leaked pass ages out quickly.
  • It is long enough that a laptop waking up or a network blip does not fail because the pass died in between.

Taking someone's access away works the same way. It applies the next time they reconnect, not mid-connection.

Eight live connections per person per document

In ticket mode, where the browser carries a pass, one person may hold up to eight open collaboration connections to one document. Every tab counts as one. The ninth is refused with "too many connections" (HTTP 429) until one of them closes.

A collaboration pass has to name a user. A pass that names none is turned away at the door with "pass names no user" (close code 4401).

Behind a proxy every such pass would share one cap and one rate window with all the others. When the service runs inside your own ASP.NET app and your login check has named the user, that user is what is counted.

Behind your own app or proxy, in the none and proxy modes of the standalone service, there is no cap at all. Nothing there names the person. Every connection arrives from the one proxy address, and a shared cap would throttle everyone together.

If you need a limit there, put it in the proxy, where you already know who is signed in.

1,024 live connections per process, everybody together

The cap above counts one person. This ceiling counts everybody together, in every auth mode.

With --collab on, the standalone service holds at most 1,024 open collaboration connections per process. So a flood of connections that are opened and then only held open cannot use up every connection the process can hold. The service keeps that count itself.

Past the ceiling a new connection is refused with "too many connections on this server" (HTTP 503), until one of the open ones closes. The refusal comes before the document is loaded, so nothing is fetched from your endpoint for a connection that will not run.

Inside your own ASP.NET app there is no ceiling unless you set CollabMaxConnections, where 0 means none. Give it the same number as Kestrel's MaxConcurrentUpgradedConnections. Kestrel stays the backstop underneath, and it refuses later and less usefully: it lets the document load first and then answers 500.

To scale out, split the documents across processes

While a document is open, exactly one service process holds its working copy in memory. Every connection to that document must reach that same process.

Two processes opening the same document would each keep a copy of their own. They would take turns overwriting each other's work in storage. So more processes only help if each document belongs to one of them.

The split is simple, because the document id is right in the connection address. Route by the document id, so every request whose path starts with /sync/{doc} lands on the same process. Anything deterministic works, from "ids starting with a through m go to the first process" to a hash of the id.

  • The reset call lives under the same path, so it automatically reaches the process that owns the document.
  • Each process then carries its own share of the documents, with its own connection ceiling.
  • All of them may share one storage folder or bucket and one document endpoint, because no two of them ever open the same document.
  • Uploads and link previews hold nothing in memory, so those requests may go to any process.

There is no built-in coordination behind this. The processes do not know about each other, and nothing in the service stops a routing mistake from opening one document twice. The routing rule is the guarantee, and it is yours to keep.

What the service does not limit for you

The service puts no cap on how many documents one person may have open. It puts no cap on how large a document may grow. It puts no cap on how often an unknown document id makes the service ask your document endpoint for a seed.

Those are your records and your rules. If you need such limits, put them in your own backend, where the permission check already lives.

What the service does bound is one connection, in five ways.

  • A single message is at most 1 MiB. A bigger one closes the connection.
  • A browser can stop taking messages, for example a frozen tab or a dead network.
    • It is closed once the messages waiting for it pass 8 MiB.
    • It catches up when it reconnects.
    • Inside your own ASP.NET app, CollabMaxMessageBytes moves both numbers together.
  • A connection that floods the service is closed with "inbound rate exceeded" (code 1008), and the editor reconnects.
    • Flooding means more than about 50 messages a second once a burst of 100 is used up.
    • It also means more than 60 requests a minute to resend the whole document or to ask everyone present to re-announce themselves.
    • And it means one message split into an absurd number of pieces. The allowance is sixteen pieces plus one more for every 64 bytes the message carries. So a flood of empty fragments ends at the seventeenth.
  • Presence is counted in bytes as well: about 128 KiB a second per connection after a burst of about 512 KiB.
    • Flooding it earns that same close.
    • Inside your own ASP.NET app the knobs are CollabInboundFramesPerSecond, CollabInboundBurstFrames, CollabInboundResyncsPerMinute and CollabInboundAwarenessBytesPerSecond.
    • A value of 0 turns any of the rates off.
  • Opening a connection spends the same per-minute --rate-limit budget as every other route.

Beyond those five, an open connection is not limited. The service tells the editor the message-size limit when it connects. So the editor refuses to send something too big instead of being disconnected for it.

One rule is about shape rather than size. The sync wire is binary, so a text message closes the connection with "binary frames only" (code 1003). Blok's editor never sends one, so that close means something else is talking to the route.

What the service does not check for you

The service checks the shape of what a write pass sends, not its meaning. Three things pass through as a result, and they are worth knowing.

  • A lone surrogate becomes U+FFFD on the wire. That is half of a UTF-16 pair, which typing never produces but a script can. Every replica then reads the replacement character, including that browser's own copy after a reload.
  • An update can integrate nothing because the changes it depends on never arrive. It is not refused. It is relayed and persisted like any other.
    • From outside, a duplicate delivery and a legitimately out-of-order change look the same. So a writer who keeps sending them grows the working copy until the document is evicted from memory, and nothing else bounds it.
  • Anything arriving through the document, another browser or an edit request, can place a block under a container whose childTools denies it. The editor places it where the document says, and logs a warning naming the child and the container. It does not demote it, because demoting would write back and loop against the peer.

The one thing that does not pass through is a block the reader cannot make sense of. A block whose id or type is not text is left out of the saved document. So is a block whose data is not a set of fields. Each one gets a warning naming it.

A value nested more than 256 levels deep is saved as null. The editor and the service do exactly the same thing here. So what a browser saves and what the service writes back to your records agree block for block.

Read-only until the first sync, editable through a dropped connection

A document opens read-only and stays that way until the first sync arrives. The editor never starts from a copy of its own. Two browsers each starting from their own copy and then syncing would duplicate every block.

Once that first sync lands the document becomes editable. This holds as long as the pass grants write and your app has not put the editor in read-only itself.

After that the behaviour is deliberately asymmetric. If the connection drops, the document stays editable. What gets typed piles up in that tab. It is shipped the moment that tab reconnects. So a tunnel or a sleeping laptop does not freeze the page under someone's hands.

The editor reports each step on the collaboration:status event. The steps are connecting, connected, offline while it keeps retrying, and error. error means it has stopped for good and will not reconnect by itself.

The event carries the list of people present, so you can render your own indicator in the shape your product already uses. Blok does not paint one for you.

None of those four says the work is saved. They describe the socket, not the queue behind it. The next entry is the one that says what has been taken.

Connected is not saved: the editor reports the two separately

Connecting, connected and offline describe the socket. None of them says the work has been taken. The same collaboration:status event carries a second block that does say it: save.

Its state is one of five.

  • saved means nothing is waiting.
  • pending means edits are queued in this tab and not acknowledged yet.
    • A connected session reports pending too, because a socket that is up is not a save.
  • blocked means the local queue broke and nothing more may be sent, with the reason local-storage-failed.
    • Editing stops for the rest of the session.
  • quarantined means edits were taken out of the queue and will never be sent, and quarantinedOperations counts them.
    • With the offline option on they stay in this browser's copy.
    • Without it only that count survives.
  • unavailable means no durable save can be reported at all.
    • That is what a service without an operation journal answers, with the reason legacy-protocol once it has said so.

Beside the state, pendingOperations and pendingBytes size the queue. serverSequence is where the journal put the last acknowledged edit. It is a decimal string, because the ceiling does not fit a JavaScript number.

  • Draw connected with pending as saving, and saved as saved.
  • Draw quarantined as an error somebody has to be told about.
  • Draw unavailable as no save indicator at all, rather than a green one.

The event carries this whether or not the connection changed. A save state that moves on its own publishes one. An identical payload publishes nothing. One replay lands shortly after the editor is ready, so a listener attached late still learns where it stands.

One caution about the counts: a local queue that cannot be read at all reports zero rows. So zero under blocked means unknown, not nothing waiting.

The boundary is exact. An edit counts as saved once the service has committed it to your operation journal. That append has to return before anybody else is shown the edit.

  • A socket send is not it.
  • A peer seeing the change is not it.
  • Neither is the whole-JSON write-back to your endpoint, which happens later and can fail on its own.

This is also why one person on their own should use it. A single-user editor pointed at the service gets the same queue, the same journal and the same receipt as ten people do. Here too, save is what an indicator should read.

Without an operation journal none of it is reachable. No flag registers one, so a stock service negotiates the legacy protocol and reports unavailable for the life of every session.

A reload while it says offline loses what was typed offline

By default those offline edits live in that tab and nowhere else until it reconnects. Reload the tab, close it or lose the browser before that happens, and they are gone. The tab was holding the only copy, and nothing had reached the working copy or your records yet.

Turning on the editor's offline option changes that. It keeps a copy of the document in that browser. A reload then picks the work back up and sends it on the next connection.

The option is off unless you ask for it. It writes document content into the visitor's browser storage. That is a decision about your data rather than about the editor.

Two things to know about the copy.

  • Nothing is kept until the first successful sync. So an editor that never reached the service still cannot start from a copy of its own.
  • The copy is thrown away whenever the service says its history no longer matches, after a reset for instance. That way a stale copy can never be mistaken for the live document.

Turning the option on requires one more thing: an offlineScope, a stable id for whoever is signed in. Browser storage belongs to the browser rather than to a person. That id is what keeps each person's copy to themselves.

The editor refuses to start with the offline option on and no offlineScope. So there is no unpartitioned copy left to get wrong.

Make that id opaque and stable: the same string every time that person signs in, on every device. Anything that rotates, such as a ticket or a session id, mints a fresh partition on every refresh. It strands every copy behind it, offline edits included.

The copy is also not a backup. Blok never asks the browser to keep it, so a browser short of space may drop it without telling anyone. Clearing site data does the same on purpose.

If you do not turn the offline option on, the guidance stays the plain one. While the editor says it is offline, leave the tab alone and let it reconnect.

Two people in one field: the last write wins

Edits to different blocks merge cleanly, and so do edits to different fields of the same block. One person retitling a heading while another ticks a checklist item never collide, and neither loses anything.

Inside one field it is coarser. Two people typing in the same paragraph at the same moment do not get their letters woven together. Whichever change lands last is the one everybody keeps, and the other is dropped.

Notion behaves the same way, and so does every editor that syncs whole fields rather than characters. Character-by-character merging is a later phase. In practice two people rarely type in one sentence at once, and presence is there so they can see when they are about to.

Who is here, and where they are

Blok draws presence itself.

  • You get small avatars for the people in the document.
  • You get a colored cursor where each of them is typing.
  • You get a small face with their initials parked in the margin beside the block they are in.

The cursor pulses once they stop moving. Hover the face for the full name. The cursor itself carries no name tag, so a document with four people in it never becomes a wall of them.

Both read the display identity you pass in the editor's collaboration option. That identity is a name, plus an optional color used for that person's avatar, face and cursor.

Leave it out and the person still shows up as a space silhouette, such as a comet, a rocket or a satellite. The silhouette comes with a matching name in the reader's own language. So nobody is a blank disc, and no two people in a document wear the same one.

This is display only. It is independent of the separate user option that stamps who last edited a block.

One answers "whose cursor is that", the other answers "who gets the credit for this edit". Set either, both or neither.

When somebody else selects text within a single block, that run is tinted in their color too. The tint is a translucent wash laid over the text, not behind it. It never intercepts a click, so you can still put your own cursor anywhere inside it. A selection that spans several blocks is not drawn at all, cursor included.

Presence is not verified

The small avatars and cursors that show who is in the document are relayed exactly as each browser sends them. The service never reads what they say, like every collaboration server built on the same sync protocol.

It does check their shape. A presence message the receiving browsers cannot read ends their session, so every one is walked and each person's state has to be valid JSON.

  • A state that is not valid is dropped and written to the log.
  • The third bad one from a single connection closes it with "malformed awareness" (code 1008), and the editor reconnects as it always does.

What is never checked is what presence claims. Any participant, including one holding a read-only pass, can announce any name and any position. Document content is what the pass protects: who may open a document and who may change it is checked at the door.

Presence is a courtesy signal for the people in the room, not an identity check. Never build permissions or an audit trail on it.

Presence covers up to 256 people in one document

A document relays presence for up to 256 people at once. Presence means the avatars, and the cursor showing where each person is typing.

Past that the presence updates are dropped instead of passed on, and the service writes a line to its log saying so. That is a drop and nothing more: a crowded room costs nobody their connection, unlike a presence message the service cannot parse at all.

Editing is untouched. Everyone keeps typing, and every change still merges and reaches your records as usual. So if a document ever gets that crowded, what breaks is people seeing each other, not people working.

This is a presence-only degradation, not a limit on how many may edit one document. There is no setting for the number. It is fixed inside the service.

A document id is one path segment

The document id travels in the connection address (/sync/{doc}) and inside the pass, and the two must match character for character. The core rule follows from that: the id has to be one path segment.

Letters, digits, dashes, underscores and dots are safe. A slash, even an encoded one, is refused at the door with a clear message, "document ids must be a single path segment". It does not fail later as a pass that mysteriously names another document.

Use your own opaque ids. If your records are addressed by paths, map each path to a token on your side and hand the editor the token.

Terminate TLS before internet traffic reaches the service

The standalone host speaks plain HTTP. It does not terminate TLS or manage certificates. An internet-facing deployment must sit behind a reverse proxy or hosting platform that accepts HTTPS and forwards HTTP over a private or loopback connection. Never send a ticket over plain internet traffic.

You already have storage

You use Supabase, S3, Cloudinary or similar, and you want uploads to go straight there.

Run nothing. The browser uploads to the storage you already pay for, using a ready-made uploader from @bloklabs/presets. This is the cheapest path and the one most people should take.

Two things it cannot do: link previews and live collaboration.

  • A preview means reading another site's page, which a browser is not allowed to do.
  • Live collaboration means carrying edits between people's browsers, which needs something running that both of them can reach.

If you want either, keep this uploader and add the service for that feature. The two do not conflict.

Storage presets, set up step by step

Editor config

Point the editor at your storage

TypeScript
import { Blok } from '@bloklabs/core';
import { supabaseStorage } from '@bloklabs/presets';

new Blok({
  holder: 'editor',
  uploader: supabaseStorage(supabase, { bucket: 'blok' }),
  // Nothing here turns on live editing: it needs the service in the middle,
  // exactly like link previews do. The three paths below run it — take one
  // and keep this uploader as it is.
});

When it goes wrong

What you seeWhyWhat to do
A pasted link stays a plain link instead of becoming a preview card.

Nothing is reading the page behind that link. A browser cannot fetch another site and read its title and image. The site has to allow it, and almost none do.

Run the service for previews only, or point the bookmark tool at a third-party preview service. If you do not want previews at all, this is already the finished state.
Pasting an image URL from another site saves that URL instead of copying the image to your storage.

Copying a remote file needs a fetch made by a server. Supabase and presigned uploads happen entirely in the browser, so there is nobody to make it.

The Cloudinary preset does this on its own, and so does the service. With the others the original URL is stored as-is. It still renders, but it depends on the other site staying up.
Two people open the same document and neither sees the other's edits.

Nothing is carrying edits between their browsers. Live collaboration needs the service in the middle, for the same reason link previews do: the browser alone cannot do it.

Run the service with --collab, pointed at the place your app saves documents. This uploader keeps working unchanged beside it.

Your app runs ASP.NET Core

You want Blok routes inside the .NET app that already owns your users and deployment.

Install the ASP.NET Core package and map the shared C# handlers in your existing process. There is no second host, port or container to operate. Your application authorization policy protects uploads and link previews.

Live collaboration runs in the same process too. Allow WebSockets, tell Blok where your documents live, and add a check that decides who may open which document. The editor calls the route prefix you choose.

Support for database blocks and MySQL integration comes later.

The package also converts saved documents in-process, to Markdown, HTML or plain text, and back from Markdown. It runs Blok's own implementation rather than a C# port of it. A port silently loses every block it was never taught.

What to run

Install the ASP.NET Core package

Terminal
dotnet add package Blok.Server.AspNetCore

The one route in your app

Register and map Blok in your app

C#
using Blok.Server.AspNetCore;

builder.Services.AddBlokServer(options =>
{
  options.StorageDirectory = "./blok-uploads";
  options.PublicUrl = "https://uploads.example.com/files";
  options.UnfurlDisabled = false;
  // Live collaboration. The service keeps a working copy of open documents
  // and loads and saves the record through your own document routes.
  options.CollabEnabled = true;
  options.DocEndpoint = "https://myapp.com/api/documents";
});

var app = builder.Build();

// Without this call the service cannot open the long-lived connection
// collaboration needs, and refuses it with a message saying so.
app.UseWebSockets();

app.MapBlokServer("/api/blok").RequireAuthorization();

Decide who may open which document

C#
using System.Security.Claims;
using Blok.Server.AspNetCore;

// Asked when someone opens a document for live collaboration, after your
// RequireAuthorization policy has already run. Without it, any signed-in
// user may open any document.
public sealed class DocumentRules : IBlokAuthorization
{
  public ValueTask<bool> CanReadDocumentAsync(
      ClaimsPrincipal user, string documentId, CancellationToken cancellationToken = default)
    => ValueTask.FromResult(true /* your own check */);

  public ValueTask<bool> CanWriteDocumentAsync(
      ClaimsPrincipal user, string documentId, CancellationToken cancellationToken = default)
    => ValueTask.FromResult(true /* your own check */);
}

// Chain it onto the registration above:
builder.Services.AddBlokServer(options => { /* as above */ })
  .UseAuthorization<DocumentRules>();

Convert documents — no storage, no route

C#
using Blok.Server.Documents;

// Registers the converter on its own. AddBlokServer includes it, but a service
// that only reads documents does not have to configure a bucket to get one.
// Its engine pool is built at startup, so no reader waits a second for it;
// pass warmUp: false in a test host that starts often and converts rarely.
builder.Services.AddBlokDocuments();

// Anywhere you already have the saved JSON — an MCP tool, an agent, an export.
public sealed class ArticleExport(IBlokDocumentConverter blok)
{
  public async Task<string> ToMarkdownAsync(string contentJson, CancellationToken ct)
  {
    var conversion = await blok.ToMarkdownAsync(contentJson, ct);

    foreach (var warning in conversion.Warnings)
    {
      // e.g. construct "callout", action "degraded", detail "rendered as a blockquote…"
      logger.LogInformation("{Construct} {Action}: {Detail}", warning.Construct, warning.Action, warning.Detail);
    }

    return conversion.Markdown;
  }
}

Translate a document without giving a model its JSON

C#
// A model handed a document's JSON breaks the structure — dropped ids,
// reordered blocks, invented fields. Hand it a flat list of strings instead.
var texts = await blok.ExtractTextsAsync(documentJson, cancellationToken: ct);
var translated = await TranslateAsync(texts, ct);
var document = await blok.InjectTextsAsync(documentJson, translated, cancellationToken: ct);

// The list is in document order, holds no URLs and no file names, and skips
// empty values. A count that does not match the document throws rather than
// misplacing a translation.

// The version a saved document carries is whatever wrote it, so ask for the
// one the editor stamps rather than inventing a number.
var version = await blok.GetVersionAsync(ct);

// The saved format as JSON Schema, for constraining a model's structured
// output or validating an import.
var schema = await blok.GetSchemaAsync(ct);

Index a document, and handle a conversion that fails

C#
// The default text is what a reader sees on screen. A search index wants
// more: the alt text of an image, the artist of a track, the URL somebody
// pasted as a bare link. That is what includeHiddenText adds.
var text = await blok.ToPlainTextAsync(contentJson, includeHiddenText: true, cancellationToken: ct);

// Conversion runs Blok's own JavaScript, so it is bounded. Every failure
// arrives as one type carrying a reason — you never catch the engine's.
try
{
  var markdown = await blok.ToMarkdownAsync(contentJson, ct);
}
catch (BlokDocumentConversionException failure)
{
  var advice = failure.Reason switch
  {
    BlokConversionFailure.DocumentTooLarge => "Split the document and try again.",
    BlokConversionFailure.TimedOut => "Split the document and try again.",
    BlokConversionFailure.InvalidDocument => "This is not a saved Blok document.",
    _ => "Retry, then report it.",
  };

  logger.LogWarning("Conversion failed ({Reason}): {Advice}", failure.Reason, advice);
}

// A conversion is bounded by a timeout and by how much the interpreter may
// allocate for one call. The budget is allocation churn, NOT resident memory,
// and nothing is reserved — raise it for documents with large inline images.
builder.Services.AddBlokDocuments(allocationBudgetBytes: 1024L * 1024 * 1024);

Editor config

Point the editor at the mapped routes

TypeScript
import { Blok } from '@bloklabs/core';

new Blok({
  holder: 'editor',
  // The prefix you mapped above. Fills in the uploader and the link-preview
  // endpoint; anything you set yourself wins.
  server: '/api/blok',
  // Live collaboration. Everyone who opens the same doc edits together, and
  // the name and color are what the other people see.
  collaboration: {
    doc: 'article-42',
    user: { name: 'Jack', color: '#f60' },
  },
});

When it goes wrong

What you seeWhyWhat to do
Upload routes return 404 while health and link previews work.

Storage is disabled, so the package does not map routes that could never complete.

Set StorageDirectory, or configure the S3 options, when calling AddBlokServer.
The app rejects its Blok configuration before it maps routes or opens storage.

MaxUploadBytes cannot be larger than Array.MaxLength when storage and remote unfurl are both enabled. RateLimitPerMinute must be zero or greater.

  • PublicUrl must be HTTP(S) or root-relative.
  • S3BucketUrl must be absolute HTTP(S).
  • Neither may contain credentials, a query or a fragment.
  • S3Endpoint must be an origin without credentials, a path, a query or a fragment, and it must use HTTPS except for loopback HTTP.
  • ListenAddress rejects a DNS host, because the standalone Kestrel host would bind every network interface.
Use a safe public URL, keep remote S3 endpoints on HTTPS, lower the buffered-upload limit when required, and bind an IP address, localhost or an explicit wildcard.
Opening a document for live collaboration fails at once with a message about WebSockets.

The app never called UseWebSockets(), so the long-lived connection collaboration needs cannot be opened. The service refuses with a message that names the missing call. It does not let the editor time out.

Add app.UseWebSockets(); before MapBlokServer, as in the sample above.
The log warns at startup that the collaboration routes are open to anyone who can reach the app.

Collaboration is on, the service is not using passes, and no document check is registered. So nothing decides who may open which document. The warning names the three routes it covers: the sync connection, its reset and its edit.

Register the document check shown above, or switch the service to passes. The warning is written once, at registration, and it cannot see your policy. So a mapped group that already carries RequireAuthorization() gets the warning too. The sentence says as much itself.

You run your own backend

You have a Django, Rails, Laravel, Node or Go app running on a machine you control.

Run the service next to your app, listening only on 127.0.0.1. That address is reachable from your own machine and from nowhere else, so nothing on the internet can call it.

Your app gets one new route that passes the request through. Your existing login check guards that route the same way it guards everything else. There is no second set of users, no second password, and no domain or certificate to arrange.

Live collaboration flows through the same route. The one extra thing your proxy must do is let its long-lived connection through, shown below.

What to run

Start the service beside your app

Terminal
docker run \
  --network host \
  --mount type=volume,source=blok-server-data,target=/data \
  ghcr.io/jackuait/blok-server \
  --listen 127.0.0.1:4000 \
  --auth proxy \
  --allow-origin https://myapp.com \
  --storage-dir /data \
  --public-url https://uploads.myapp.com/files

The one route in your app

Add one forwarding route to your app

TypeScript
import { createProxyMiddleware } from 'http-proxy-middleware';

// Everything under /api/blok goes to the service. requireLogin is your own
// existing check — the service trusts whatever reaches it, so this line is
// what decides who may upload.
app.use(
  '/api/blok',
  requireLogin,
  createProxyMiddleware({
    target: 'http://127.0.0.1:4000',
    pathRewrite: { '^/api/blok': '' },
    // Live collaboration holds a connection open through this route.
    // Without ws: true uploads work and collaboration never connects.
    ws: true,
  }),
);

Editor config

Point the editor at your own route

TypeScript
import { Blok } from '@bloklabs/core';

new Blok({
  holder: 'editor',
  // Fills in the uploader and the link-preview endpoint. Anything you set
  // yourself wins, so your own storage still works alongside it.
  server: '/api/blok',
  // Live collaboration. Everyone who opens the same doc edits together, and
  // the name and color are what the other people see.
  collaboration: {
    doc: 'article-42',
    user: { name: 'Jack', color: '#f60' },
  },
});

When it goes wrong

What you seeWhyWhat to do
The container prints "blok-server refused to start" and exits immediately.

It was told to listen on an address other than 127.0.0.1 while running in a mode that checks nothing. Refusing is deliberate: starting would have put an unauthenticated service on the internet.

Keep --listen on 127.0.0.1. If you really need the service reachable from outside, that is the third path below, and it uses --auth ticket.
Uploads come back as 404 while link previews work.

That deployment has no storage configured, so the upload routes are not registered at all.

Give the service somewhere to put files with --storage-dir, or point it at an S3-compatible bucket.
Uploads succeed, but the images are broken for everyone except you.

--public-url is the address the browser loads stored files from. It defaults to the loopback address the service listens on. That address means "this machine" on every machine, so it works for you and nobody else.

Set --public-url to a real address, and point that hostname at the service or at a web server holding the same directory. Use a different hostname than your app. The reason is in the section above.
Large uploads fail before they reach the service.

Every uploaded file travels through your app on this path, so your app's own request-size limit applies first.

Raise the body-size limit on the forwarding route, or take the storage-only path above, where files never touch your app.
One busy user makes everyone else hit "rate limit exceeded".

Every request arrives at the service from your app. From the service's point of view they all come from the same caller and share one allowance.

Limit per user in your own app, on the forwarding route, where you already know who is signed in.
You point the editor straight at http://127.0.0.1:4000 during development and the browser blocks the request.

The service only answers a browser that arrives from an address it was told to expect, and by default it was told none.

Go through your app's own route as shown above, or pass --allow-origin with your dev server's address.

You are on Vercel or Netlify

Your app is deployed to a host that runs your code on demand. Nothing runs beside it.

There is no machine to sit the service next to, so it is deployed on its own (Fly, Railway, Render, a small VPS) under a hostname you own. It is now reachable from the internet, so it checks every request. The hosting platform or a reverse proxy must terminate TLS before forwarding plain HTTP to the service.

Your app adds one route that hands the browser a short-lived pass naming the signed-in user. The service checks the pass by itself, with no call back to your app. It then lets the request through or refuses it.

Live collaboration runs on the same deployment. The service keeps a working copy of the documents people are editing. It loads and saves the record through your app's own document routes.

What to run

Deploy the service under a hostname you own

Terminal
docker run \
  -p 127.0.0.1:4000:4000 \
  --mount type=volume,source=blok-server-data,target=/data \
  -e BLOK_SECRET \
  ghcr.io/jackuait/blok-server \
  --listen 0.0.0.0:4000 \
  --auth ticket \
  --allow-origin https://myapp.com \
  --storage-dir /data \
  --public-url https://blok.myapp.com/files

The same service, with live collaboration

Terminal
docker run \
  -p 127.0.0.1:4000:4000 \
  --mount type=volume,source=blok-server-data,target=/data \
  --mount type=volume,source=blok-collab,target=/collab \
  -e BLOK_SECRET \
  -e BLOK_DOC_ENDPOINT_AUTH \
  ghcr.io/jackuait/blok-server \
  --listen 0.0.0.0:4000 \
  --auth ticket \
  --allow-origin https://myapp.com \
  --storage-dir /data \
  --public-url https://blok.myapp.com/files \
  --collab \
  --collab-dir /collab \
  --doc-endpoint https://myapp.com/api/documents

The one route in your app

Add one route that hands out a pass

TypeScript
// app/api/blok-ticket/route.ts
import { blokTicket } from '@bloklabs/server/ticket';

export async function GET(request: Request) {
  const session = await getSession();

  if (!session) {
    return new Response('Not signed in', { status: 401 });
  }

  // With live collaboration on, the editor asks for a pass naming the
  // document it is opening (?doc=…). This is where your own permission
  // check runs: refuse here and that person never gets in. A collaboration
  // pass is checked once, when the connection opens, so it gets a longer
  // life than the five-minute upload default.
  const doc = new URL(request.url).searchParams.get('doc');

  return Response.json({
    ticket: blokTicket(process.env.BLOK_SECRET, {
      user: session.userId,
      write: true,
      ...(doc === null ? {} : { doc, ttlSeconds: 30 * 60 }),
    }),
  });
}

The same route, if your backend is not JavaScript

TypeScript
// A pass is a standard HS256 JWT and nothing more. Every language has a
// one-line library for it; this is spelled out so you can see there is no
// magic in it, and so you can port it wherever your backend lives.
//
//   header   {"alg":"HS256","typ":"JWT"}   these two keys, in this order
//   claims   user   your own user id, stored but never interpreted
//            doc    for live collaboration, the one document this pass
//                   may open; upload and preview routes ignore it
//            write  false may read previews; uploads need true
//            exp    seconds since the epoch; keep the life short
//   secret   the same BLOK_SECRET the service runs with, 32 chars or more
//
// Both segments are base64url with the padding stripped, joined with a dot,
// and the signature is HMAC-SHA256 over that string, base64url again.
import { createHmac } from 'node:crypto';

const b64 = (value: string) => Buffer.from(value).toString('base64url');

const head = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = b64(
  JSON.stringify({
    user: session.userId,
    write: true,
    exp: Math.floor(Date.now() / 1000) + 60 * 60,
  }),
);
const signature = createHmac('sha256', process.env.BLOK_SECRET ?? '')
  .update(`${head}.${body}`)
  .digest('base64url');

const ticket = `${head}.${body}.${signature}`;

Editor config

Point the editor at the service, carrying the pass

TypeScript
import { Blok } from '@bloklabs/core';

new Blok({
  holder: 'editor',
  server: 'https://blok.myapp.com',
  // The editor fetches a pass from this route, keeps it, and replaces it
  // shortly before it expires. Uploads and link previews share one; live
  // collaboration asks the same route for its own, naming the document.
  ticket: '/api/blok-ticket',
  // Live collaboration. Everyone who opens the same doc edits together, and
  // the name and color are what the other people see.
  collaboration: {
    doc: 'article-42',
    user: { name: 'Jack', color: '#f60' },
  },
});

When it goes wrong

What you seeWhyWhat to do
The service refuses to start and complains about the secret.

The shared secret is shorter than 32 characters, or missing. A short one is almost always a password somebody typed.

Generate a long random one with openssl rand -base64 48. Put it in the BLOK_SECRET environment variable on both sides. The service and the app that mints passes have to hold the same string.
The service refuses to start and asks for an origin.

Anyone can reach the service, and it has no list of who may call it. Someone can use it to fire requests at other sites from your address. It will not start that way.

Pass --allow-origin with your app's address. List several by separating them with commas.
Every request comes back 403 "origin not allowed", including ones from your own scripts.

In this mode the service refuses anything that does not say which site it came from. Browsers omit that on same-origin requests. Non-browser callers omit it entirely.

Send an Origin header that matches one of the --allow-origin values. Check for typos: the scheme and port are part of the match.
A signed pass can load link previews but cannot upload files.

A pass with write: false may call only the unfurl route. Both upload routes require write: true.

Mint upload passes with write: true. Use write: false when a caller should be limited to link previews.
Uploads worked, then started coming back 401 after the tab had been open a while.

The pass expired. That is what makes it safe to hand to a browser.

Mint a new one per request, as the uploader config above does. Give it a lifetime measured in minutes or hours, not days.
Uploads keep working but link previews start failing after an hour.

The bookmark tool takes a fixed set of headers when the editor is created. So it keeps sending the pass it was given at startup.

Give the bookmark tool a longer-lived pass. You can also route link previews through a small endpoint in your own app that adds a fresh one.
Uploads work, but a live-collaboration session drops the moment it opens.

The pass was turned away at the collaboration door. A collaboration pass must name the document being opened in its doc claim. It must also still be alive when the editor connects. A pass without the claim, for a different document, or already expired is refused.

Mint collaboration passes with doc set to the document being opened. Check first that the user may see it. Give each pass about 30 minutes of life.
A document opens read-only with a reconnecting notice and never becomes editable.

The service could not load the document from your routes. The --doc-endpoint address is wrong, the endpoint is down, or it refused the request. The service will not open an empty document and later save it over your record, so it refuses the session. The editor shows the last copy it has read-only while it retries.

Check that --doc-endpoint points at your document routes. Check that BLOK_DOC_ENDPOINT_AUTH holds the header value they expect. Once the endpoint answers, the next retry connects and the document becomes editable.