Recipe: Public share links

Publish a document as a public, read-only link anyone can open — no account needed. This is @civility/cloud on top of a synced app. The full template (civ init --template full) is the complete working version of everything below; this recipe is the tour.

Sharing rides on sync: a share is a record in the reserved _civility.shares collection that syncs like any other, but the server interprets it — a record activates a public share, a tombstone revokes it. So the app needs a synced store and a connected server before it can share.

1. Turn on sharing #

Add a share block. CloudStore declares and wires the reserved _civility.shares control collection for you — it only exists when you ask for it, so an app that never shares doesn’t advertise one to the server.

// www/store/app.ts
import { CloudStore } from '@civility/cloud'

const cloud = new CloudStore(backend, {
  collections: ['notes'],
  versions: [/* … */],
  cloud: { appId: APP_ID },
  share: { appShareUrl: APP_URL }, // your app's own URL; publish appends ?id=&server=
})
const notes = cloud.collection<Note>('notes')

2. Reach the publisher through cloud.share #

The publisher needs the server origin, which only exists once the user has connected (in Settings, via <civ-sync-input>). cloud.share resolves it from the live connection, so all your app has to answer is “are we connected?”:

async shared() {
  // `restore()` resumes a saved session; false means the user hasn't set up
  // sync yet, and the UI should prompt for it.
  if (!cloud.connected && !(await cloud.restore())) return null
  return cloud.share
}

cloud.canShare is the same check without the restore attempt, for rendering a disabled button.

3. Wire the share UI #

The civ-share-* components are host-agnostic — you pass plain callbacks that call the publisher. In an editor, a <civ-share-dialog> publishes the current document:

#publisher: SharePublisher = {
  publish: async () => {
    const shared = await app.shared()
    if (!shared) throw new Error('Connect to a sync server in Settings first.')
    const { id, url } = await shared.publish({ collection: 'notes', docId })
    return { id, url, label: app.get(docId)?.title }
  },
  revoke: (id) => app.shared().then((s) => s?.revoke(id)),
}
// <civ-share-dialog .open=${open} .methods=${this.#publisher} .share=${view}>

A <civ-shares-list> in Settings manages every share (shared.list()ShareView[], wired to a revoke callback).

4. Render the share on your own domain #

The framework ships no generic viewer — apps render their own shares (spec §13.2). A share URL is <appShareUrl>?id=<id>&server=<server>, so detect those params at boot and fetch the document with getShare — no session required:

// www/index.ts
const p = new URLSearchParams(location.search)
if (p.get('id') && p.get('server')) {
  const note = await getShare<Note>(APP_ID, p.get('id')!, {
    server: p.get('server')!,
  })
  // render read-only. The server is untrusted — validate the shape, and render
  // as text (Lit interpolation escapes it) or pass a Zod `schema` to getShare.
}

Using the site root + a ?id= query keeps deep links working on any static host (no rewrite rules), the same reason apps use hash routing.

Encryption follows the app #

The above ships plaintext shares (cleartext, served by reference) — the simplest posture. In an end-to-end encrypted app, add mode: 'e2e' to the share block (plus blobs if the shared docs reference any): each publish mints a per-share key, uploads an encrypted copy, and carries the key in the URL #k fragment, which getShare takes as { key }. The server never sees plaintext either way.


The full working app — notes CRUD, sync, share dialog, shares list, and the public viewer — is civ init --template full.