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.