···11+# Blento Embed SDK — protocol v0
22+33+The Blento Embed SDK lets a third-party app (e.g. `atmo.rsvp`) hosted inside a
44+Blento page perform AT Proto writes on behalf of the visitor — without ever
55+seeing their session, cookies, or tokens. All writes are mediated by Blento's
66+server using the visitor's signed-in OAuth session.
77+88+## Architecture
99+1010+```
1111+┌─ blento.app/<user> (top window, visitor signed in) ─────┐
1212+│ │
1313+│ ┌─ atmo.rsvp/embed/<event> (iframe, your app) ─────┐ │
1414+│ │ <script src="https://blento.app/embed/v0/sdk.js">│
1515+│ │ window.Blento.createRecord({...}) │ │
1616+│ │ ↓ postMessage to window.parent │ │
1717+│ └──────────────────────────────────────────────────┘ │
1818+│ │
1919+│ The Blento page runs an <AtmoEmbed> host component │
2020+│ that listens for messages, validates the origin and │
2121+│ the requested collection, and calls Blento's server │
2222+│ which talks to the visitor's PDS. │
2323+└─────────────────────────────────────────────────────────┘
2424+```
2525+2626+The iframe is **inside** Blento. There is no third level. There is no consent
2727+popup, no Storage Access API dance, no embed token. The visitor's session is
2828+first-party on `blento.app`, and `iframe → window.parent` postMessage works
2929+out of the box across origins.
3030+3131+## Loading the SDK
3232+3333+Add this to the embed page on your origin (e.g. `atmo.rsvp/embed/...`):
3434+3535+```html
3636+<script src="https://blento.app/embed/v0/sdk.js"></script>
3737+```
3838+3939+The script exposes `window.Blento` synchronously and starts a handshake with
4040+the parent. Wait for `Blento.ready` before any write.
4141+4242+## Trust model
4343+4444+Each origin is added to a hardcoded server-side allowlist with the collection
4545+NSID prefixes it may write. v0 ships with:
4646+4747+| Origin | Allowed collection prefixes |
4848+| ------------------- | ----------------------------- |
4949+| `https://atmo.rsvp` | `community.lexicon.calendar.` |
5050+5151+Adding a new origin or collection requires:
5252+5353+1. Adding the entry to `src/lib/embed/allowlist.ts`.
5454+2. If the collection isn't already in `src/lib/atproto/settings.ts`'s
5555+ `collections` list, add it there too — Blento's OAuth scope only covers
5656+ collections it explicitly requests.
5757+5858+There is **no runtime consent UI** — visiting a Blento page that contains
5959+your embed implies trust. Don't request scopes a user wouldn't expect.
6060+6161+## URL parameters
6262+6363+Blento appends these query params to your iframe `src`. Parse them as soon as
6464+your script runs (before paint, ideally) so initial render reflects the
6565+parent's theme:
6666+6767+| Param | Type | Meaning |
6868+| -------- | ----------- | --------------------------------------------------------------------- |
6969+| `base` | string | Tailwind neutral palette: `gray`, `stone`, `zinc`, `neutral`, `slate` |
7070+| `accent` | string | Tailwind vivid palette: `red`, `pink`, `blue`, `…` |
7171+| `dark` | `'1'`/`'0'` | Whether the parent is rendering dark mode |
7272+| `did` | string | Visitor's DID (same value `getSession()` will report after `ready`) |
7373+7474+`Blento.getTheme()` returns `{ base, accent, dark }` parsed from the URL.
7575+You're free to apply them however you like — set CSS variables, add classes,
7676+swap stylesheets.
7777+7878+## API reference — `window.Blento`
7979+8080+### `Blento.ready: Promise<void>`
8181+8282+Resolves once the parent has confirmed the handshake and reported a session
8383+(which may be `null`). Rejects with `BlentoError({ code: 'unknown' })` after
8484+~10s if the parent never responds (e.g. your page was loaded standalone, not
8585+in a Blento frame). Always `await` this before any write.
8686+8787+```js
8888+await Blento.ready;
8989+```
9090+9191+### `Blento.getTheme(): { base, accent, dark }`
9292+9393+Synchronous; returns parsed URL params. Available before `ready`.
9494+9595+### `Blento.getSession(): Session | null`
9696+9797+Synchronous; returns the visitor's session or `null` if they aren't signed in
9898+to Blento. Updated by parent over time. Available after `ready`.
9999+100100+```ts
101101+type Session = {
102102+ did: string;
103103+ handle?: string;
104104+ displayName?: string;
105105+ avatar?: string;
106106+};
107107+```
108108+109109+### `Blento.on('session', cb): () => void`
110110+111111+Subscribe to session changes (e.g. visitor logs in or out in another tab).
112112+Returns an unsubscribe function.
113113+114114+```js
115115+const off = Blento.on('session', (s) => updateUI(s));
116116+// later: off();
117117+```
118118+119119+### `Blento.createRecord({ collection, rkey?, record }): Promise<{ uri, cid? }>`
120120+121121+Calls `com.atproto.repo.createRecord` on the visitor's PDS. `rkey` is
122122+auto-generated if omitted. Resolves with the new record's URI.
123123+124124+### `Blento.putRecord({ collection, rkey, record }): Promise<{ uri, cid? }>`
125125+126126+`com.atproto.repo.putRecord` (create-or-update at known rkey).
127127+128128+### `Blento.deleteRecord({ collection, rkey }): Promise<{ ok: boolean }>`
129129+130130+`com.atproto.repo.deleteRecord`.
131131+132132+### `Blento.applyWrites({ writes, validate? }): Promise<{ results }>`
133133+134134+Atomic batch via `com.atproto.repo.applyWrites`. Each write is one of:
135135+136136+```ts
137137+type Write =
138138+ | { $type: 'create'; collection: string; rkey?: string; value: object }
139139+ | { $type: 'update'; collection: string; rkey: string; value: object }
140140+ | { $type: 'delete'; collection: string; rkey: string };
141141+```
142142+143143+Resolves with `{ results: Array<{ uri?, cid? }> }` in the same order as input.
144144+145145+### `Blento.uploadBlob(blob, opts?): Promise<BlobRef>`
146146+147147+Uploads a `Blob` to the visitor's PDS via `com.atproto.repo.uploadBlob`. Pass
148148+the result inline in a subsequent record write.
149149+150150+```ts
151151+type BlobRef = {
152152+ $type: 'blob';
153153+ ref: { $link: string };
154154+ mimeType: string;
155155+ size: number;
156156+};
157157+```
158158+159159+`opts.mimeType` overrides `blob.type` if provided.
160160+161161+### `Blento.notifyResize(heightPx: number): void`
162162+163163+Hint for the parent to resize the iframe. The parent clamps to a sane range
164164+(80px–20000px). Compute the height however you like — `ResizeObserver` on the
165165+body is a typical choice.
166166+167167+### `Blento.notifyNavigate(url: string): void`
168168+169169+Ask the parent to navigate top-level. The parent only honors **same-origin**
170170+URLs (i.e. paths within `blento.app`). Useful after creating a record:
171171+172172+```js
173173+const { uri } = await Blento.createRecord({ ... });
174174+const rkey = uri.split('/').pop();
175175+Blento.notifyNavigate(`/${session.did}/event/r/${rkey}`);
176176+```
177177+178178+## Errors
179179+180180+All write rejections are `BlentoError` instances with a stable `.code`:
181181+182182+| Code | Meaning |
183183+| ----------------- | ----------------------------------------------------------------- |
184184+| `no_session` | Visitor is not signed in to Blento |
185185+| `user_cancelled` | User declined a confirmation prompt (reserved; not emitted in v0) |
186186+| `rate_limited` | PDS throttled the request (reserved) |
187187+| `pds_error` | The visitor's PDS rejected the write |
188188+| `unsupported` | Method not available in this protocol version |
189189+| `invalid_request` | Origin not allowed, collection not allowed, or malformed payload |
190190+| `unknown` | Anything else |
191191+192192+```js
193193+try {
194194+ await Blento.createRecord({ ... });
195195+} catch (e) {
196196+ if (e.code === 'no_session') showLoginPrompt();
197197+ else if (e.code === 'pds_error') retryLater();
198198+ else console.error(e);
199199+}
200200+```
201201+202202+## Wire protocol (for partners not using the SDK)
203203+204204+The SDK is plain JS and easy to drop in, but the protocol is small enough to
205205+implement directly. All messages include `v: 0`.
206206+207207+### iframe → parent (`window.parent.postMessage(msg, '*')`)
208208+209209+```
210210+{ v: 0, id, type: 'hello' } // handshake
211211+{ v: 0, id, type: 'createRecord', payload: { collection, rkey?, record } }
212212+{ v: 0, id, type: 'putRecord', payload: { collection, rkey, record } }
213213+{ v: 0, id, type: 'deleteRecord', payload: { collection, rkey } }
214214+{ v: 0, id, type: 'applyWrites', payload: { writes, validate? } }
215215+{ v: 0, id, type: 'uploadBlob', payload: { bytes: number[], mimeType } }
216216+{ v: 0, type: 'blento:resize', heightPx } // unsolicited
217217+{ v: 0, type: 'blento:navigate', url } // unsolicited
218218+```
219219+220220+`id` is any unique string you generate — the parent echoes it on the response.
221221+222222+### parent → iframe (`iframe.contentWindow.postMessage(msg, '<your origin>')`)
223223+224224+```
225225+{ v: 0, type: 'ready', session } // once after hello
226226+{ v: 0, type: 'session', session } // on session change
227227+{ v: 0, id, ok: true, result } // request response
228228+{ v: 0, id, ok: false, error: { code, message } } // request error
229229+```
230230+231231+The parent ignores any message whose `event.origin` doesn't match the iframe's
232232+`src` origin or whose `event.source` isn't the iframe's contentWindow.
233233+234234+### Blob transfer
235235+236236+For `uploadBlob`, the SDK serializes the blob's bytes as a `number[]` array
237237+(JSON-friendly). If you implement the protocol directly:
238238+239239+```js
240240+const buf = await blob.arrayBuffer();
241241+const bytes = Array.from(new Uint8Array(buf));
242242+parent.postMessage({ v: 0, id, type: 'uploadBlob', payload: { bytes, mimeType: blob.type } }, '*');
243243+```
244244+245245+This is inefficient for large blobs (~4× JSON overhead). Future protocol
246246+versions may use structured-clone or transferable streams.
247247+248248+## Local development
249249+250250+The SDK and a test harness ship with the Blento dev server.
251251+252252+1. Run Blento: `pnpm dev` (defaults to `http://localhost:5173`).
253253+2. Sign in to Blento at `/login`.
254254+3. Visit `http://localhost:5173/embed-test`.
255255+256256+The test harness page (`/embed/v0/test.html`) is hosted as a static asset on
257257+the same origin. The `<AtmoEmbed>` component there points at it. The dev
258258+allowlist permits `http://localhost:5173`, `http://localhost:5174`, and the
259259+`127.0.0.1` equivalents — so you can also serve your in-development partner
260260+app on a separate port (e.g. `http://localhost:5174`) to exercise the
261261+cross-origin postMessage path. To do that, change the `path`/`origin` props on
262262+`/embed-test/+page.svelte` accordingly.
263263+264264+## Versioning
265265+266266+The URL `/embed/v0/sdk.js` is locked. Any breaking change to the protocol or
267267+SDK surface ships at a new version (`/embed/v1/sdk.js`) — old embeds keep
268268+working unchanged. Within v0, additions are backwards-compatible (new
269269+optional fields, new methods).
270270+271271+When v1 lands, the parent's `<AtmoEmbed>` host will dispatch by the `v` field
272272+and support both, so you can roll over partner apps independently.
+234
src/lib/embed/AtmoEmbed.svelte
···11+<script lang="ts">
22+ import { onMount, untrack } from 'svelte';
33+ import { browser } from '$app/environment';
44+ import { page } from '$app/state';
55+ import { user } from '$lib/atproto';
66+ import {
77+ embedApplyWrites,
88+ embedCreateRecord,
99+ embedDeleteRecord,
1010+ embedPutRecord,
1111+ embedUploadBlob
1212+ } from './embed.remote';
1313+1414+ type Props = {
1515+ origin: string;
1616+ path: string;
1717+ allowedCollectionPrefixes: string[];
1818+ height?: number;
1919+ minHeight?: number;
2020+ maxHeight?: number;
2121+ title?: string;
2222+ class?: string;
2323+ };
2424+2525+ let {
2626+ origin,
2727+ path,
2828+ allowedCollectionPrefixes,
2929+ height = 400,
3030+ minHeight = 80,
3131+ maxHeight = 20000,
3232+ title = 'Embedded content',
3333+ class: className = ''
3434+ }: Props = $props();
3535+3636+ const PROTOCOL_VERSION = 0;
3737+3838+ type Session = {
3939+ did: string;
4040+ handle?: string;
4141+ displayName?: string;
4242+ avatar?: string;
4343+ } | null;
4444+4545+ let iframeEl: HTMLIFrameElement | null = $state(null);
4646+ let resizedHeight: number | null = $state(null);
4747+ let displayHeight = $derived(resizedHeight ?? height);
4848+ let handshakeDone = $state(false);
4949+5050+ const session: Session = $derived.by<Session>(() => {
5151+ if (!user.did) return null;
5252+ return {
5353+ did: user.did,
5454+ handle: user.profile?.handle,
5555+ displayName: user.profile?.displayName ?? undefined,
5656+ avatar: user.profile?.avatar ?? undefined
5757+ };
5858+ });
5959+6060+ function isAllowedCollectionLocal(collection: string): boolean {
6161+ return allowedCollectionPrefixes.some((p) => {
6262+ if (p === '*') return true;
6363+ const stripped = p.replace(/\.$/, '');
6464+ return collection === stripped || collection.startsWith(p);
6565+ });
6666+ }
6767+6868+ function computeIframeSrc(): string {
6969+ const prefs = page.data?.publication?.preferences;
7070+ const baseColor = prefs?.baseColor ?? 'stone';
7171+ const accentColor = prefs?.accentColor ?? 'pink';
7272+7373+ let dark = false;
7474+ if (browser) {
7575+ const root = document.documentElement;
7676+ if (root.classList.contains('dark')) dark = true;
7777+ else if (root.classList.contains('light')) dark = false;
7878+ else dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
7979+ }
8080+8181+ const url = new URL(path, origin);
8282+ url.searchParams.set('base', baseColor);
8383+ url.searchParams.set('accent', accentColor);
8484+ url.searchParams.set('dark', dark ? '1' : '0');
8585+ if (user.did) url.searchParams.set('did', user.did);
8686+ return url.toString();
8787+ }
8888+8989+ function postToIframe(message: unknown) {
9090+ if (!iframeEl?.contentWindow) return;
9191+ iframeEl.contentWindow.postMessage(message, origin);
9292+ }
9393+9494+ function mapError(err: unknown): { code: string; message: string } {
9595+ const msg = err instanceof Error ? err.message : String(err);
9696+ if (msg.includes('no_session')) return { code: 'no_session', message: msg };
9797+ if (msg.includes('origin_not_allowed') || msg.includes('collection_not_allowed')) {
9898+ return { code: 'invalid_request', message: msg };
9999+ }
100100+ if (msg.includes('pds_error')) return { code: 'pds_error', message: msg };
101101+ return { code: 'unknown', message: msg };
102102+ }
103103+104104+ type RequestPayload = Record<string, unknown> | undefined;
105105+106106+ async function handleRequest(id: string, type: string, payload: RequestPayload) {
107107+ try {
108108+ const p = (payload ?? {}) as Record<string, unknown>;
109109+110110+ if (type === 'createRecord' || type === 'putRecord' || type === 'deleteRecord') {
111111+ const collection = p.collection as string | undefined;
112112+ if (typeof collection !== 'string') throw new Error('invalid_request');
113113+ if (!isAllowedCollectionLocal(collection)) throw new Error('collection_not_allowed');
114114+ } else if (type === 'applyWrites') {
115115+ const writes = (p.writes ?? []) as Array<{ collection?: string }>;
116116+ for (const w of writes) {
117117+ if (typeof w.collection !== 'string') throw new Error('invalid_request');
118118+ if (!isAllowedCollectionLocal(w.collection)) throw new Error('collection_not_allowed');
119119+ }
120120+ }
121121+122122+ let result: unknown;
123123+ switch (type) {
124124+ case 'createRecord':
125125+ result = await embedCreateRecord({
126126+ origin,
127127+ collection: p.collection as string,
128128+ rkey: p.rkey as string | undefined,
129129+ record: (p.record ?? {}) as Record<string, unknown>
130130+ });
131131+ break;
132132+ case 'putRecord':
133133+ result = await embedPutRecord({
134134+ origin,
135135+ collection: p.collection as string,
136136+ rkey: p.rkey as string,
137137+ record: (p.record ?? {}) as Record<string, unknown>
138138+ });
139139+ break;
140140+ case 'deleteRecord':
141141+ result = await embedDeleteRecord({
142142+ origin,
143143+ collection: p.collection as string,
144144+ rkey: p.rkey as string
145145+ });
146146+ break;
147147+ case 'applyWrites':
148148+ result = await embedApplyWrites({
149149+ origin,
150150+ writes: (p.writes ?? []) as Parameters<typeof embedApplyWrites>[0]['writes'],
151151+ validate: p.validate as boolean | undefined
152152+ });
153153+ break;
154154+ case 'uploadBlob':
155155+ result = await embedUploadBlob({
156156+ origin,
157157+ bytes: (p.bytes ?? []) as number[],
158158+ mimeType: (p.mimeType ?? 'application/octet-stream') as string
159159+ });
160160+ break;
161161+ default:
162162+ throw new Error('unsupported');
163163+ }
164164+165165+ postToIframe({ v: PROTOCOL_VERSION, id, ok: true, result });
166166+ } catch (err) {
167167+ const mapped = mapError(err);
168168+ postToIframe({ v: PROTOCOL_VERSION, id, ok: false, error: mapped });
169169+ }
170170+ }
171171+172172+ function handleMessage(ev: MessageEvent) {
173173+ if (!iframeEl) return;
174174+ if (ev.source !== iframeEl.contentWindow) return;
175175+ if (ev.origin !== origin) return;
176176+177177+ const data = ev.data;
178178+ if (!data || typeof data !== 'object') return;
179179+ if (data.v !== PROTOCOL_VERSION) return;
180180+181181+ if (data.type === 'hello') {
182182+ handshakeDone = true;
183183+ postToIframe({ v: PROTOCOL_VERSION, type: 'ready', session });
184184+ return;
185185+ }
186186+187187+ if (data.type === 'blento:resize' && typeof data.heightPx === 'number') {
188188+ resizedHeight = Math.max(minHeight, Math.min(maxHeight, data.heightPx));
189189+ return;
190190+ }
191191+192192+ if (data.type === 'blento:navigate' && typeof data.url === 'string') {
193193+ try {
194194+ const target = new URL(data.url, window.location.href);
195195+ if (target.origin === window.location.origin) {
196196+ window.location.href = target.toString();
197197+ }
198198+ } catch {
199199+ /* ignore malformed URLs */
200200+ }
201201+ return;
202202+ }
203203+204204+ if (typeof data.id === 'string' && typeof data.type === 'string') {
205205+ handleRequest(data.id, data.type, data.payload);
206206+ }
207207+ }
208208+209209+ $effect(() => {
210210+ if (!handshakeDone) return;
211211+ const current = session;
212212+ untrack(() => {
213213+ postToIframe({ v: PROTOCOL_VERSION, type: 'session', session: current });
214214+ });
215215+ });
216216+217217+ onMount(() => {
218218+ if (iframeEl && !iframeEl.src) {
219219+ iframeEl.src = computeIframeSrc();
220220+ }
221221+ window.addEventListener('message', handleMessage);
222222+ return () => window.removeEventListener('message', handleMessage);
223223+ });
224224+</script>
225225+226226+<iframe
227227+ bind:this={iframeEl}
228228+ {title}
229229+ class={className}
230230+ style:height="{displayHeight}px"
231231+ style:width="100%"
232232+ style:border="0"
233233+ style:display="block"
234234+></iframe>