your personal website on atproto - mirror blento.app
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

add v0

Florian 02dac70c dc165249

+1347
+272
docs/embed-sdk/v0.md
··· 1 + # Blento Embed SDK — protocol v0 2 + 3 + The Blento Embed SDK lets a third-party app (e.g. `atmo.rsvp`) hosted inside a 4 + Blento page perform AT Proto writes on behalf of the visitor — without ever 5 + seeing their session, cookies, or tokens. All writes are mediated by Blento's 6 + server using the visitor's signed-in OAuth session. 7 + 8 + ## Architecture 9 + 10 + ``` 11 + ┌─ blento.app/<user> (top window, visitor signed in) ─────┐ 12 + │ │ 13 + │ ┌─ atmo.rsvp/embed/<event> (iframe, your app) ─────┐ │ 14 + │ │ <script src="https://blento.app/embed/v0/sdk.js">│ 15 + │ │ window.Blento.createRecord({...}) │ │ 16 + │ │ ↓ postMessage to window.parent │ │ 17 + │ └──────────────────────────────────────────────────┘ │ 18 + │ │ 19 + │ The Blento page runs an <AtmoEmbed> host component │ 20 + │ that listens for messages, validates the origin and │ 21 + │ the requested collection, and calls Blento's server │ 22 + │ which talks to the visitor's PDS. │ 23 + └─────────────────────────────────────────────────────────┘ 24 + ``` 25 + 26 + The iframe is **inside** Blento. There is no third level. There is no consent 27 + popup, no Storage Access API dance, no embed token. The visitor's session is 28 + first-party on `blento.app`, and `iframe → window.parent` postMessage works 29 + out of the box across origins. 30 + 31 + ## Loading the SDK 32 + 33 + Add this to the embed page on your origin (e.g. `atmo.rsvp/embed/...`): 34 + 35 + ```html 36 + <script src="https://blento.app/embed/v0/sdk.js"></script> 37 + ``` 38 + 39 + The script exposes `window.Blento` synchronously and starts a handshake with 40 + the parent. Wait for `Blento.ready` before any write. 41 + 42 + ## Trust model 43 + 44 + Each origin is added to a hardcoded server-side allowlist with the collection 45 + NSID prefixes it may write. v0 ships with: 46 + 47 + | Origin | Allowed collection prefixes | 48 + | ------------------- | ----------------------------- | 49 + | `https://atmo.rsvp` | `community.lexicon.calendar.` | 50 + 51 + Adding a new origin or collection requires: 52 + 53 + 1. Adding the entry to `src/lib/embed/allowlist.ts`. 54 + 2. If the collection isn't already in `src/lib/atproto/settings.ts`'s 55 + `collections` list, add it there too — Blento's OAuth scope only covers 56 + collections it explicitly requests. 57 + 58 + There is **no runtime consent UI** — visiting a Blento page that contains 59 + your embed implies trust. Don't request scopes a user wouldn't expect. 60 + 61 + ## URL parameters 62 + 63 + Blento appends these query params to your iframe `src`. Parse them as soon as 64 + your script runs (before paint, ideally) so initial render reflects the 65 + parent's theme: 66 + 67 + | Param | Type | Meaning | 68 + | -------- | ----------- | --------------------------------------------------------------------- | 69 + | `base` | string | Tailwind neutral palette: `gray`, `stone`, `zinc`, `neutral`, `slate` | 70 + | `accent` | string | Tailwind vivid palette: `red`, `pink`, `blue`, `…` | 71 + | `dark` | `'1'`/`'0'` | Whether the parent is rendering dark mode | 72 + | `did` | string | Visitor's DID (same value `getSession()` will report after `ready`) | 73 + 74 + `Blento.getTheme()` returns `{ base, accent, dark }` parsed from the URL. 75 + You're free to apply them however you like — set CSS variables, add classes, 76 + swap stylesheets. 77 + 78 + ## API reference — `window.Blento` 79 + 80 + ### `Blento.ready: Promise<void>` 81 + 82 + Resolves once the parent has confirmed the handshake and reported a session 83 + (which may be `null`). Rejects with `BlentoError({ code: 'unknown' })` after 84 + ~10s if the parent never responds (e.g. your page was loaded standalone, not 85 + in a Blento frame). Always `await` this before any write. 86 + 87 + ```js 88 + await Blento.ready; 89 + ``` 90 + 91 + ### `Blento.getTheme(): { base, accent, dark }` 92 + 93 + Synchronous; returns parsed URL params. Available before `ready`. 94 + 95 + ### `Blento.getSession(): Session | null` 96 + 97 + Synchronous; returns the visitor's session or `null` if they aren't signed in 98 + to Blento. Updated by parent over time. Available after `ready`. 99 + 100 + ```ts 101 + type Session = { 102 + did: string; 103 + handle?: string; 104 + displayName?: string; 105 + avatar?: string; 106 + }; 107 + ``` 108 + 109 + ### `Blento.on('session', cb): () => void` 110 + 111 + Subscribe to session changes (e.g. visitor logs in or out in another tab). 112 + Returns an unsubscribe function. 113 + 114 + ```js 115 + const off = Blento.on('session', (s) => updateUI(s)); 116 + // later: off(); 117 + ``` 118 + 119 + ### `Blento.createRecord({ collection, rkey?, record }): Promise<{ uri, cid? }>` 120 + 121 + Calls `com.atproto.repo.createRecord` on the visitor's PDS. `rkey` is 122 + auto-generated if omitted. Resolves with the new record's URI. 123 + 124 + ### `Blento.putRecord({ collection, rkey, record }): Promise<{ uri, cid? }>` 125 + 126 + `com.atproto.repo.putRecord` (create-or-update at known rkey). 127 + 128 + ### `Blento.deleteRecord({ collection, rkey }): Promise<{ ok: boolean }>` 129 + 130 + `com.atproto.repo.deleteRecord`. 131 + 132 + ### `Blento.applyWrites({ writes, validate? }): Promise<{ results }>` 133 + 134 + Atomic batch via `com.atproto.repo.applyWrites`. Each write is one of: 135 + 136 + ```ts 137 + type Write = 138 + | { $type: 'create'; collection: string; rkey?: string; value: object } 139 + | { $type: 'update'; collection: string; rkey: string; value: object } 140 + | { $type: 'delete'; collection: string; rkey: string }; 141 + ``` 142 + 143 + Resolves with `{ results: Array<{ uri?, cid? }> }` in the same order as input. 144 + 145 + ### `Blento.uploadBlob(blob, opts?): Promise<BlobRef>` 146 + 147 + Uploads a `Blob` to the visitor's PDS via `com.atproto.repo.uploadBlob`. Pass 148 + the result inline in a subsequent record write. 149 + 150 + ```ts 151 + type BlobRef = { 152 + $type: 'blob'; 153 + ref: { $link: string }; 154 + mimeType: string; 155 + size: number; 156 + }; 157 + ``` 158 + 159 + `opts.mimeType` overrides `blob.type` if provided. 160 + 161 + ### `Blento.notifyResize(heightPx: number): void` 162 + 163 + Hint for the parent to resize the iframe. The parent clamps to a sane range 164 + (80px–20000px). Compute the height however you like — `ResizeObserver` on the 165 + body is a typical choice. 166 + 167 + ### `Blento.notifyNavigate(url: string): void` 168 + 169 + Ask the parent to navigate top-level. The parent only honors **same-origin** 170 + URLs (i.e. paths within `blento.app`). Useful after creating a record: 171 + 172 + ```js 173 + const { uri } = await Blento.createRecord({ ... }); 174 + const rkey = uri.split('/').pop(); 175 + Blento.notifyNavigate(`/${session.did}/event/r/${rkey}`); 176 + ``` 177 + 178 + ## Errors 179 + 180 + All write rejections are `BlentoError` instances with a stable `.code`: 181 + 182 + | Code | Meaning | 183 + | ----------------- | ----------------------------------------------------------------- | 184 + | `no_session` | Visitor is not signed in to Blento | 185 + | `user_cancelled` | User declined a confirmation prompt (reserved; not emitted in v0) | 186 + | `rate_limited` | PDS throttled the request (reserved) | 187 + | `pds_error` | The visitor's PDS rejected the write | 188 + | `unsupported` | Method not available in this protocol version | 189 + | `invalid_request` | Origin not allowed, collection not allowed, or malformed payload | 190 + | `unknown` | Anything else | 191 + 192 + ```js 193 + try { 194 + await Blento.createRecord({ ... }); 195 + } catch (e) { 196 + if (e.code === 'no_session') showLoginPrompt(); 197 + else if (e.code === 'pds_error') retryLater(); 198 + else console.error(e); 199 + } 200 + ``` 201 + 202 + ## Wire protocol (for partners not using the SDK) 203 + 204 + The SDK is plain JS and easy to drop in, but the protocol is small enough to 205 + implement directly. All messages include `v: 0`. 206 + 207 + ### iframe → parent (`window.parent.postMessage(msg, '*')`) 208 + 209 + ``` 210 + { v: 0, id, type: 'hello' } // handshake 211 + { v: 0, id, type: 'createRecord', payload: { collection, rkey?, record } } 212 + { v: 0, id, type: 'putRecord', payload: { collection, rkey, record } } 213 + { v: 0, id, type: 'deleteRecord', payload: { collection, rkey } } 214 + { v: 0, id, type: 'applyWrites', payload: { writes, validate? } } 215 + { v: 0, id, type: 'uploadBlob', payload: { bytes: number[], mimeType } } 216 + { v: 0, type: 'blento:resize', heightPx } // unsolicited 217 + { v: 0, type: 'blento:navigate', url } // unsolicited 218 + ``` 219 + 220 + `id` is any unique string you generate — the parent echoes it on the response. 221 + 222 + ### parent → iframe (`iframe.contentWindow.postMessage(msg, '<your origin>')`) 223 + 224 + ``` 225 + { v: 0, type: 'ready', session } // once after hello 226 + { v: 0, type: 'session', session } // on session change 227 + { v: 0, id, ok: true, result } // request response 228 + { v: 0, id, ok: false, error: { code, message } } // request error 229 + ``` 230 + 231 + The parent ignores any message whose `event.origin` doesn't match the iframe's 232 + `src` origin or whose `event.source` isn't the iframe's contentWindow. 233 + 234 + ### Blob transfer 235 + 236 + For `uploadBlob`, the SDK serializes the blob's bytes as a `number[]` array 237 + (JSON-friendly). If you implement the protocol directly: 238 + 239 + ```js 240 + const buf = await blob.arrayBuffer(); 241 + const bytes = Array.from(new Uint8Array(buf)); 242 + parent.postMessage({ v: 0, id, type: 'uploadBlob', payload: { bytes, mimeType: blob.type } }, '*'); 243 + ``` 244 + 245 + This is inefficient for large blobs (~4× JSON overhead). Future protocol 246 + versions may use structured-clone or transferable streams. 247 + 248 + ## Local development 249 + 250 + The SDK and a test harness ship with the Blento dev server. 251 + 252 + 1. Run Blento: `pnpm dev` (defaults to `http://localhost:5173`). 253 + 2. Sign in to Blento at `/login`. 254 + 3. Visit `http://localhost:5173/embed-test`. 255 + 256 + The test harness page (`/embed/v0/test.html`) is hosted as a static asset on 257 + the same origin. The `<AtmoEmbed>` component there points at it. The dev 258 + allowlist permits `http://localhost:5173`, `http://localhost:5174`, and the 259 + `127.0.0.1` equivalents — so you can also serve your in-development partner 260 + app on a separate port (e.g. `http://localhost:5174`) to exercise the 261 + cross-origin postMessage path. To do that, change the `path`/`origin` props on 262 + `/embed-test/+page.svelte` accordingly. 263 + 264 + ## Versioning 265 + 266 + The URL `/embed/v0/sdk.js` is locked. Any breaking change to the protocol or 267 + SDK surface ships at a new version (`/embed/v1/sdk.js`) — old embeds keep 268 + working unchanged. Within v0, additions are backwards-compatible (new 269 + optional fields, new methods). 270 + 271 + When v1 lands, the parent's `<AtmoEmbed>` host will dispatch by the `v` field 272 + and support both, so you can roll over partner apps independently.
+234
src/lib/embed/AtmoEmbed.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, untrack } from 'svelte'; 3 + import { browser } from '$app/environment'; 4 + import { page } from '$app/state'; 5 + import { user } from '$lib/atproto'; 6 + import { 7 + embedApplyWrites, 8 + embedCreateRecord, 9 + embedDeleteRecord, 10 + embedPutRecord, 11 + embedUploadBlob 12 + } from './embed.remote'; 13 + 14 + type Props = { 15 + origin: string; 16 + path: string; 17 + allowedCollectionPrefixes: string[]; 18 + height?: number; 19 + minHeight?: number; 20 + maxHeight?: number; 21 + title?: string; 22 + class?: string; 23 + }; 24 + 25 + let { 26 + origin, 27 + path, 28 + allowedCollectionPrefixes, 29 + height = 400, 30 + minHeight = 80, 31 + maxHeight = 20000, 32 + title = 'Embedded content', 33 + class: className = '' 34 + }: Props = $props(); 35 + 36 + const PROTOCOL_VERSION = 0; 37 + 38 + type Session = { 39 + did: string; 40 + handle?: string; 41 + displayName?: string; 42 + avatar?: string; 43 + } | null; 44 + 45 + let iframeEl: HTMLIFrameElement | null = $state(null); 46 + let resizedHeight: number | null = $state(null); 47 + let displayHeight = $derived(resizedHeight ?? height); 48 + let handshakeDone = $state(false); 49 + 50 + const session: Session = $derived.by<Session>(() => { 51 + if (!user.did) return null; 52 + return { 53 + did: user.did, 54 + handle: user.profile?.handle, 55 + displayName: user.profile?.displayName ?? undefined, 56 + avatar: user.profile?.avatar ?? undefined 57 + }; 58 + }); 59 + 60 + function isAllowedCollectionLocal(collection: string): boolean { 61 + return allowedCollectionPrefixes.some((p) => { 62 + if (p === '*') return true; 63 + const stripped = p.replace(/\.$/, ''); 64 + return collection === stripped || collection.startsWith(p); 65 + }); 66 + } 67 + 68 + function computeIframeSrc(): string { 69 + const prefs = page.data?.publication?.preferences; 70 + const baseColor = prefs?.baseColor ?? 'stone'; 71 + const accentColor = prefs?.accentColor ?? 'pink'; 72 + 73 + let dark = false; 74 + if (browser) { 75 + const root = document.documentElement; 76 + if (root.classList.contains('dark')) dark = true; 77 + else if (root.classList.contains('light')) dark = false; 78 + else dark = window.matchMedia('(prefers-color-scheme: dark)').matches; 79 + } 80 + 81 + const url = new URL(path, origin); 82 + url.searchParams.set('base', baseColor); 83 + url.searchParams.set('accent', accentColor); 84 + url.searchParams.set('dark', dark ? '1' : '0'); 85 + if (user.did) url.searchParams.set('did', user.did); 86 + return url.toString(); 87 + } 88 + 89 + function postToIframe(message: unknown) { 90 + if (!iframeEl?.contentWindow) return; 91 + iframeEl.contentWindow.postMessage(message, origin); 92 + } 93 + 94 + function mapError(err: unknown): { code: string; message: string } { 95 + const msg = err instanceof Error ? err.message : String(err); 96 + if (msg.includes('no_session')) return { code: 'no_session', message: msg }; 97 + if (msg.includes('origin_not_allowed') || msg.includes('collection_not_allowed')) { 98 + return { code: 'invalid_request', message: msg }; 99 + } 100 + if (msg.includes('pds_error')) return { code: 'pds_error', message: msg }; 101 + return { code: 'unknown', message: msg }; 102 + } 103 + 104 + type RequestPayload = Record<string, unknown> | undefined; 105 + 106 + async function handleRequest(id: string, type: string, payload: RequestPayload) { 107 + try { 108 + const p = (payload ?? {}) as Record<string, unknown>; 109 + 110 + if (type === 'createRecord' || type === 'putRecord' || type === 'deleteRecord') { 111 + const collection = p.collection as string | undefined; 112 + if (typeof collection !== 'string') throw new Error('invalid_request'); 113 + if (!isAllowedCollectionLocal(collection)) throw new Error('collection_not_allowed'); 114 + } else if (type === 'applyWrites') { 115 + const writes = (p.writes ?? []) as Array<{ collection?: string }>; 116 + for (const w of writes) { 117 + if (typeof w.collection !== 'string') throw new Error('invalid_request'); 118 + if (!isAllowedCollectionLocal(w.collection)) throw new Error('collection_not_allowed'); 119 + } 120 + } 121 + 122 + let result: unknown; 123 + switch (type) { 124 + case 'createRecord': 125 + result = await embedCreateRecord({ 126 + origin, 127 + collection: p.collection as string, 128 + rkey: p.rkey as string | undefined, 129 + record: (p.record ?? {}) as Record<string, unknown> 130 + }); 131 + break; 132 + case 'putRecord': 133 + result = await embedPutRecord({ 134 + origin, 135 + collection: p.collection as string, 136 + rkey: p.rkey as string, 137 + record: (p.record ?? {}) as Record<string, unknown> 138 + }); 139 + break; 140 + case 'deleteRecord': 141 + result = await embedDeleteRecord({ 142 + origin, 143 + collection: p.collection as string, 144 + rkey: p.rkey as string 145 + }); 146 + break; 147 + case 'applyWrites': 148 + result = await embedApplyWrites({ 149 + origin, 150 + writes: (p.writes ?? []) as Parameters<typeof embedApplyWrites>[0]['writes'], 151 + validate: p.validate as boolean | undefined 152 + }); 153 + break; 154 + case 'uploadBlob': 155 + result = await embedUploadBlob({ 156 + origin, 157 + bytes: (p.bytes ?? []) as number[], 158 + mimeType: (p.mimeType ?? 'application/octet-stream') as string 159 + }); 160 + break; 161 + default: 162 + throw new Error('unsupported'); 163 + } 164 + 165 + postToIframe({ v: PROTOCOL_VERSION, id, ok: true, result }); 166 + } catch (err) { 167 + const mapped = mapError(err); 168 + postToIframe({ v: PROTOCOL_VERSION, id, ok: false, error: mapped }); 169 + } 170 + } 171 + 172 + function handleMessage(ev: MessageEvent) { 173 + if (!iframeEl) return; 174 + if (ev.source !== iframeEl.contentWindow) return; 175 + if (ev.origin !== origin) return; 176 + 177 + const data = ev.data; 178 + if (!data || typeof data !== 'object') return; 179 + if (data.v !== PROTOCOL_VERSION) return; 180 + 181 + if (data.type === 'hello') { 182 + handshakeDone = true; 183 + postToIframe({ v: PROTOCOL_VERSION, type: 'ready', session }); 184 + return; 185 + } 186 + 187 + if (data.type === 'blento:resize' && typeof data.heightPx === 'number') { 188 + resizedHeight = Math.max(minHeight, Math.min(maxHeight, data.heightPx)); 189 + return; 190 + } 191 + 192 + if (data.type === 'blento:navigate' && typeof data.url === 'string') { 193 + try { 194 + const target = new URL(data.url, window.location.href); 195 + if (target.origin === window.location.origin) { 196 + window.location.href = target.toString(); 197 + } 198 + } catch { 199 + /* ignore malformed URLs */ 200 + } 201 + return; 202 + } 203 + 204 + if (typeof data.id === 'string' && typeof data.type === 'string') { 205 + handleRequest(data.id, data.type, data.payload); 206 + } 207 + } 208 + 209 + $effect(() => { 210 + if (!handshakeDone) return; 211 + const current = session; 212 + untrack(() => { 213 + postToIframe({ v: PROTOCOL_VERSION, type: 'session', session: current }); 214 + }); 215 + }); 216 + 217 + onMount(() => { 218 + if (iframeEl && !iframeEl.src) { 219 + iframeEl.src = computeIframeSrc(); 220 + } 221 + window.addEventListener('message', handleMessage); 222 + return () => window.removeEventListener('message', handleMessage); 223 + }); 224 + </script> 225 + 226 + <iframe 227 + bind:this={iframeEl} 228 + {title} 229 + class={className} 230 + style:height="{displayHeight}px" 231 + style:width="100%" 232 + style:border="0" 233 + style:display="block" 234 + ></iframe>
+43
src/lib/embed/allowlist.ts
··· 1 + import { dev } from '$app/environment'; 2 + 3 + export type AllowlistEntry = { 4 + collectionPrefixes: string[]; 5 + label: string; 6 + }; 7 + 8 + const PROD_ALLOWLIST: Record<string, AllowlistEntry> = { 9 + 'https://atmo.rsvp': { 10 + collectionPrefixes: ['community.lexicon.calendar.'], 11 + label: 'atmo.rsvp' 12 + } 13 + }; 14 + 15 + const DEV_ALLOWLIST: Record<string, AllowlistEntry> = { 16 + 'http://localhost:5173': { collectionPrefixes: ['*'], label: 'Local dev (5173)' }, 17 + 'http://localhost:5174': { collectionPrefixes: ['*'], label: 'Local dev (5174)' }, 18 + 'http://127.0.0.1:5173': { collectionPrefixes: ['*'], label: 'Local dev (5173 IP)' }, 19 + 'http://127.0.0.1:5174': { collectionPrefixes: ['*'], label: 'Local dev (5174 IP)' } 20 + }; 21 + 22 + export const ALLOWLIST: Record<string, AllowlistEntry> = dev 23 + ? { ...PROD_ALLOWLIST, ...DEV_ALLOWLIST } 24 + : PROD_ALLOWLIST; 25 + 26 + export function getAllowlistEntry(origin: string): AllowlistEntry | null { 27 + return ALLOWLIST[origin] ?? null; 28 + } 29 + 30 + export function isAllowedOrigin(origin: string): boolean { 31 + return origin in ALLOWLIST; 32 + } 33 + 34 + function matchesPrefix(collection: string, prefix: string): boolean { 35 + if (prefix === '*') return true; 36 + return collection === prefix.replace(/\.$/, '') || collection.startsWith(prefix); 37 + } 38 + 39 + export function isAllowedCollection(origin: string, collection: string): boolean { 40 + const entry = ALLOWLIST[origin]; 41 + if (!entry) return false; 42 + return entry.collectionPrefixes.some((p) => matchesPrefix(collection, p)); 43 + }
+266
src/lib/embed/embed.remote.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { command, getRequestEvent } from '$app/server'; 3 + import * as v from 'valibot'; 4 + import { isAllowedCollection, isAllowedOrigin } from './allowlist'; 5 + import { contrail, ensureInit } from '$lib/contrail'; 6 + 7 + const originSchema = v.string(); 8 + 9 + const collectionSchema = v.pipe( 10 + v.string(), 11 + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/) 12 + ); 13 + 14 + const rkeySchema = v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/)); 15 + 16 + const recordSchema = v.record(v.string(), v.unknown()); 17 + 18 + const writeSchema = v.union([ 19 + v.object({ 20 + $type: v.literal('create'), 21 + collection: collectionSchema, 22 + rkey: v.optional(rkeySchema), 23 + value: recordSchema 24 + }), 25 + v.object({ 26 + $type: v.literal('update'), 27 + collection: collectionSchema, 28 + rkey: rkeySchema, 29 + value: recordSchema 30 + }), 31 + v.object({ 32 + $type: v.literal('delete'), 33 + collection: collectionSchema, 34 + rkey: rkeySchema 35 + }) 36 + ]); 37 + 38 + function requireAuth() { 39 + const { locals } = getRequestEvent(); 40 + if (!locals.client || !locals.did) error(401, 'no_session'); 41 + return { client: locals.client, did: locals.did }; 42 + } 43 + 44 + function checkOrigin(origin: string) { 45 + if (!isAllowedOrigin(origin)) error(403, 'origin_not_allowed'); 46 + } 47 + 48 + function checkCollection(origin: string, collection: string) { 49 + if (!isAllowedCollection(origin, collection)) error(403, 'collection_not_allowed'); 50 + } 51 + 52 + async function notifyContrail(uri: string) { 53 + const { platform } = getRequestEvent(); 54 + const db = platform?.env?.DB; 55 + if (!db) return; 56 + await ensureInit(db); 57 + await contrail.notify(uri, db).catch(() => {}); 58 + } 59 + 60 + export const embedCreateRecord = command( 61 + v.object({ 62 + origin: originSchema, 63 + collection: collectionSchema, 64 + rkey: v.optional(rkeySchema), 65 + record: recordSchema 66 + }), 67 + async ({ origin, collection, rkey, record }) => { 68 + const { client, did } = requireAuth(); 69 + checkOrigin(origin); 70 + checkCollection(origin, collection); 71 + 72 + const response = await client.post('com.atproto.repo.createRecord', { 73 + input: { 74 + collection: collection as `${string}.${string}.${string}`, 75 + repo: did, 76 + rkey, 77 + record 78 + } 79 + }); 80 + 81 + if (!response.ok) { 82 + console.error('embedCreateRecord failed', { 83 + origin, 84 + collection, 85 + status: response.status, 86 + data: response.data 87 + }); 88 + error(502, 'pds_error'); 89 + } 90 + 91 + await notifyContrail(response.data.uri); 92 + 93 + return { uri: response.data.uri, cid: response.data.cid }; 94 + } 95 + ); 96 + 97 + export const embedPutRecord = command( 98 + v.object({ 99 + origin: originSchema, 100 + collection: collectionSchema, 101 + rkey: rkeySchema, 102 + record: recordSchema 103 + }), 104 + async ({ origin, collection, rkey, record }) => { 105 + const { client, did } = requireAuth(); 106 + checkOrigin(origin); 107 + checkCollection(origin, collection); 108 + 109 + const valueWithType = record.$type === collection ? record : { ...record, $type: collection }; 110 + 111 + const response = await client.post('com.atproto.repo.putRecord', { 112 + input: { 113 + collection: collection as `${string}.${string}.${string}`, 114 + repo: did, 115 + rkey, 116 + record: valueWithType 117 + } 118 + }); 119 + 120 + if (!response.ok) { 121 + console.error('embedPutRecord failed', { 122 + origin, 123 + collection, 124 + rkey, 125 + status: response.status, 126 + data: response.data 127 + }); 128 + error(502, 'pds_error'); 129 + } 130 + 131 + await notifyContrail(response.data.uri); 132 + 133 + return { uri: response.data.uri, cid: response.data.cid }; 134 + } 135 + ); 136 + 137 + export const embedDeleteRecord = command( 138 + v.object({ 139 + origin: originSchema, 140 + collection: collectionSchema, 141 + rkey: rkeySchema 142 + }), 143 + async ({ origin, collection, rkey }) => { 144 + const { client, did } = requireAuth(); 145 + checkOrigin(origin); 146 + checkCollection(origin, collection); 147 + 148 + const response = await client.post('com.atproto.repo.deleteRecord', { 149 + input: { 150 + collection: collection as `${string}.${string}.${string}`, 151 + repo: did, 152 + rkey 153 + } 154 + }); 155 + 156 + if (response.ok) { 157 + await notifyContrail(`at://${did}/${collection}/${rkey}`); 158 + } 159 + 160 + return { ok: response.ok }; 161 + } 162 + ); 163 + 164 + export const embedApplyWrites = command( 165 + v.object({ 166 + origin: originSchema, 167 + writes: v.array(writeSchema), 168 + validate: v.optional(v.boolean()) 169 + }), 170 + async ({ origin, writes, validate }) => { 171 + const { client, did } = requireAuth(); 172 + checkOrigin(origin); 173 + for (const w of writes) checkCollection(origin, w.collection); 174 + 175 + const atprotoWrites = writes.map((w) => { 176 + if (w.$type === 'create') { 177 + return { 178 + $type: 'com.atproto.repo.applyWrites#create' as const, 179 + collection: w.collection as `${string}.${string}.${string}`, 180 + rkey: w.rkey, 181 + value: 182 + (w.value as { $type?: string }).$type === w.collection 183 + ? w.value 184 + : { ...w.value, $type: w.collection } 185 + }; 186 + } 187 + if (w.$type === 'update') { 188 + return { 189 + $type: 'com.atproto.repo.applyWrites#update' as const, 190 + collection: w.collection as `${string}.${string}.${string}`, 191 + rkey: w.rkey, 192 + value: 193 + (w.value as { $type?: string }).$type === w.collection 194 + ? w.value 195 + : { ...w.value, $type: w.collection } 196 + }; 197 + } 198 + return { 199 + $type: 'com.atproto.repo.applyWrites#delete' as const, 200 + collection: w.collection as `${string}.${string}.${string}`, 201 + rkey: w.rkey 202 + }; 203 + }); 204 + 205 + const response = await client.post('com.atproto.repo.applyWrites', { 206 + input: { repo: did, validate, writes: atprotoWrites } 207 + }); 208 + 209 + if (!response.ok) { 210 + console.error('embedApplyWrites failed', { 211 + origin, 212 + count: writes.length, 213 + status: response.status, 214 + data: response.data 215 + }); 216 + error(502, 'pds_error'); 217 + } 218 + 219 + const results = 220 + response.data.results?.map((r) => ({ 221 + uri: 'uri' in r ? (r.uri as string | undefined) : undefined, 222 + cid: 'cid' in r ? (r.cid as string | undefined) : undefined 223 + })) ?? []; 224 + 225 + for (const r of results) { 226 + if (r.uri) await notifyContrail(r.uri); 227 + } 228 + 229 + return { results }; 230 + } 231 + ); 232 + 233 + export const embedUploadBlob = command( 234 + v.object({ 235 + origin: originSchema, 236 + bytes: v.array(v.number()), 237 + mimeType: v.string() 238 + }), 239 + async ({ origin, bytes, mimeType }) => { 240 + const { client } = requireAuth(); 241 + checkOrigin(origin); 242 + 243 + const blob = new Blob([new Uint8Array(bytes)], { type: mimeType }); 244 + 245 + const response = await client.post('com.atproto.repo.uploadBlob', { 246 + input: blob 247 + }); 248 + 249 + if (!response.ok) { 250 + console.error('embedUploadBlob failed', { 251 + origin, 252 + size: bytes.length, 253 + status: response.status, 254 + data: response.data 255 + }); 256 + error(502, 'pds_error'); 257 + } 258 + 259 + return response.data.blob as { 260 + $type: 'blob'; 261 + ref: { $link: string }; 262 + mimeType: string; 263 + size: number; 264 + }; 265 + } 266 + );
+7
src/routes/embed-test/+page.server.ts
··· 1 + import { dev } from '$app/environment'; 2 + import { error } from '@sveltejs/kit'; 3 + 4 + export const load = () => { 5 + if (!dev) error(404); 6 + return {}; 7 + };
+39
src/routes/embed-test/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import AtmoEmbed from '$lib/embed/AtmoEmbed.svelte'; 4 + import { user } from '$lib/atproto'; 5 + 6 + let origin = $state(''); 7 + 8 + onMount(() => { 9 + origin = window.location.origin; 10 + }); 11 + </script> 12 + 13 + <svelte:head> 14 + <title>Embed SDK · v0 test</title> 15 + </svelte:head> 16 + 17 + <main class="mx-auto max-w-3xl space-y-4 p-6"> 18 + <header class="space-y-1"> 19 + <h1 class="text-xl font-semibold">Embed SDK · v0 test</h1> 20 + <p class="text-sm opacity-70"> 21 + Hosts <code>/embed/v0/test.html</code> via the <code>AtmoEmbed</code> component. Logged-in session 22 + is forwarded to the iframe. 23 + </p> 24 + <p class="text-sm opacity-70"> 25 + Logged in as: <strong>{user.profile?.handle ?? user.did ?? 'not signed in'}</strong> 26 + </p> 27 + </header> 28 + 29 + {#if origin} 30 + <AtmoEmbed 31 + {origin} 32 + path="/embed/v0/test.html" 33 + allowedCollectionPrefixes={['*']} 34 + height={700} 35 + title="Embed SDK test harness" 36 + class="w-full rounded-lg border border-black/10 dark:border-white/10" 37 + /> 38 + {/if} 39 + </main>
+229
static/embed/v0/sdk.js
··· 1 + /*! 2 + * Blento Embed SDK — protocol v0 3 + * 4 + * Loaded by third-party iframes hosted inside a Blento page (e.g. atmo.rsvp event embeds). 5 + * Exposes window.Blento, which talks to the parent Blento window via postMessage. 6 + * The parent forwards authenticated AT Proto writes to the user's PDS using the visitor's 7 + * Blento session — no tokens or cookies are exposed to the iframe. 8 + * 9 + * ─── Wire protocol (iframe → parent) ───────────────────────────────────────── 10 + * { v: 0, id, type: 'hello' } 11 + * { v: 0, id, type: 'getSession' } 12 + * { v: 0, id, type: 'createRecord', payload: { collection, rkey?, record } } 13 + * { v: 0, id, type: 'putRecord', payload: { collection, rkey, record } } 14 + * { v: 0, id, type: 'deleteRecord', payload: { collection, rkey } } 15 + * { v: 0, id, type: 'applyWrites', payload: { writes, validate? } } 16 + * { v: 0, id, type: 'uploadBlob', payload: { bytes: number[], mimeType } } 17 + * { v: 0, type: 'blento:resize', heightPx } 18 + * { v: 0, type: 'blento:navigate', url } 19 + * 20 + * ─── Wire protocol (parent → iframe) ───────────────────────────────────────── 21 + * { v: 0, type: 'ready', session } // sent once after handshake 22 + * { v: 0, type: 'session', session } // on session change 23 + * { v: 0, id, ok: true, result } // response to a request 24 + * { v: 0, id, ok: false, error: { code, message } } // error response 25 + * 26 + * ─── Session shape ─────────────────────────────────────────────────────────── 27 + * { did, handle, displayName?, avatar?, pdsUrl } | null 28 + * 29 + * ─── BlobRef shape (returned by uploadBlob) ────────────────────────────────── 30 + * { $type: 'blob', ref: { $link: string }, mimeType: string, size: number } 31 + * 32 + * ─── Write shape (applyWrites payload) ─────────────────────────────────────── 33 + * { $type: 'create', collection, rkey?, value } 34 + * { $type: 'update', collection, rkey, value } 35 + * { $type: 'delete', collection, rkey } 36 + * 37 + * ─── Theme (URL params on iframe src) ──────────────────────────────────────── 38 + * ?base=stone&accent=pink&dark=1&did=did:plc:... 39 + * - base: one of Tailwind's neutral palettes (gray, stone, zinc, neutral, slate) 40 + * - accent: one of Tailwind's vivid palettes (red, pink, blue, …) 41 + * - dark: '1' if parent is in dark mode, '0' or absent otherwise 42 + * - did: visitor's DID (same value getSession() will report after ready) 43 + * 44 + * ─── Error codes ───────────────────────────────────────────────────────────── 45 + * no_session | user_cancelled | rate_limited | pds_error 46 + * unsupported | invalid_request | unknown 47 + */ 48 + (function () { 49 + 'use strict'; 50 + 51 + if (typeof window === 'undefined') return; 52 + if (window.Blento) return; 53 + 54 + var PROTOCOL_VERSION = 0; 55 + var READY_TIMEOUT_MS = 10000; 56 + var ERROR_CODES = [ 57 + 'no_session', 58 + 'user_cancelled', 59 + 'rate_limited', 60 + 'pds_error', 61 + 'unsupported', 62 + 'invalid_request', 63 + 'unknown' 64 + ]; 65 + 66 + function BlentoError(code, message, cause) { 67 + var err = new Error(message || code); 68 + err.name = 'BlentoError'; 69 + err.code = ERROR_CODES.indexOf(code) >= 0 ? code : 'unknown'; 70 + if (cause !== undefined) err.cause = cause; 71 + Object.setPrototypeOf(err, BlentoError.prototype); 72 + return err; 73 + } 74 + BlentoError.prototype = Object.create(Error.prototype); 75 + BlentoError.prototype.constructor = BlentoError; 76 + 77 + var params = new URLSearchParams(window.location.search); 78 + var theme = Object.freeze({ 79 + base: params.get('base'), 80 + accent: params.get('accent'), 81 + dark: params.get('dark') === '1' 82 + }); 83 + 84 + var session = null; 85 + var sessionListeners = new Set(); 86 + var pending = new Map(); 87 + var nextId = 1; 88 + 89 + var readyResolve, readyReject; 90 + var ready = new Promise(function (resolve, reject) { 91 + readyResolve = resolve; 92 + readyReject = reject; 93 + }); 94 + var readySettled = false; 95 + function settleReady(ok, value) { 96 + if (readySettled) return; 97 + readySettled = true; 98 + if (ok) readyResolve(value); 99 + else readyReject(value); 100 + } 101 + 102 + function sendToParent(msg) { 103 + try { 104 + window.parent.postMessage(msg, '*'); 105 + } catch (e) { 106 + /* parent may be gone */ 107 + } 108 + } 109 + 110 + function call(type, payload) { 111 + return new Promise(function (resolve, reject) { 112 + var id = 'r' + nextId++; 113 + pending.set(id, { resolve: resolve, reject: reject }); 114 + sendToParent({ v: PROTOCOL_VERSION, id: id, type: type, payload: payload }); 115 + }); 116 + } 117 + 118 + function notifySessionListeners() { 119 + sessionListeners.forEach(function (cb) { 120 + try { 121 + cb(session); 122 + } catch (e) { 123 + /* swallow */ 124 + } 125 + }); 126 + } 127 + 128 + function handleMessage(ev) { 129 + if (ev.source !== window.parent) return; 130 + var data = ev.data; 131 + if (!data || typeof data !== 'object') return; 132 + if (data.v !== PROTOCOL_VERSION) return; 133 + 134 + if (data.type === 'ready') { 135 + session = data.session || null; 136 + settleReady(true); 137 + return; 138 + } 139 + 140 + if (data.type === 'session') { 141 + session = data.session || null; 142 + notifySessionListeners(); 143 + return; 144 + } 145 + 146 + if (data.id && pending.has(data.id)) { 147 + var entry = pending.get(data.id); 148 + pending.delete(data.id); 149 + if (data.ok) { 150 + entry.resolve(data.result); 151 + } else { 152 + var err = data.error || {}; 153 + entry.reject(new BlentoError(err.code, err.message)); 154 + } 155 + } 156 + } 157 + 158 + window.addEventListener('message', handleMessage); 159 + 160 + function on(event, cb) { 161 + if (event !== 'session') { 162 + throw new BlentoError('unsupported', 'Unknown event: ' + event); 163 + } 164 + sessionListeners.add(cb); 165 + return function () { 166 + sessionListeners.delete(cb); 167 + }; 168 + } 169 + 170 + function uploadBlob(blob, opts) { 171 + var mimeType = (opts && opts.mimeType) || blob.type || 'application/octet-stream'; 172 + return blob.arrayBuffer().then(function (buffer) { 173 + var bytes = Array.from(new Uint8Array(buffer)); 174 + return call('uploadBlob', { bytes: bytes, mimeType: mimeType }); 175 + }); 176 + } 177 + 178 + var Blento = { 179 + ready: ready, 180 + getTheme: function () { 181 + return { base: theme.base, accent: theme.accent, dark: theme.dark }; 182 + }, 183 + getSession: function () { 184 + return session; 185 + }, 186 + on: on, 187 + createRecord: function (opts) { 188 + return call('createRecord', opts); 189 + }, 190 + putRecord: function (opts) { 191 + return call('putRecord', opts); 192 + }, 193 + deleteRecord: function (opts) { 194 + return call('deleteRecord', opts); 195 + }, 196 + applyWrites: function (opts) { 197 + return call('applyWrites', opts); 198 + }, 199 + uploadBlob: uploadBlob, 200 + notifyResize: function (heightPx) { 201 + sendToParent({ v: PROTOCOL_VERSION, type: 'blento:resize', heightPx: heightPx }); 202 + }, 203 + notifyNavigate: function (url) { 204 + sendToParent({ v: PROTOCOL_VERSION, type: 'blento:navigate', url: url }); 205 + } 206 + }; 207 + 208 + Object.freeze(Blento); 209 + 210 + Object.defineProperty(window, 'Blento', { 211 + value: Blento, 212 + writable: false, 213 + configurable: false 214 + }); 215 + 216 + sendToParent({ v: PROTOCOL_VERSION, type: 'hello' }); 217 + 218 + setTimeout(function () { 219 + if (!readySettled) { 220 + settleReady( 221 + false, 222 + new BlentoError( 223 + 'unknown', 224 + 'Blento parent did not respond within ' + READY_TIMEOUT_MS + 'ms' 225 + ) 226 + ); 227 + } 228 + }, READY_TIMEOUT_MS); 229 + })();
+257
static/embed/v0/test.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>Blento Embed SDK · v0 test harness</title> 7 + <style> 8 + * { 9 + box-sizing: border-box; 10 + } 11 + body { 12 + margin: 0; 13 + padding: 1rem; 14 + font: 15 + 14px/1.5 system-ui, 16 + sans-serif; 17 + color: #111; 18 + background: #fafafa; 19 + } 20 + html.dark body { 21 + color: #f5f5f5; 22 + background: #18181b; 23 + } 24 + h1 { 25 + font-size: 1rem; 26 + margin: 0 0 1rem; 27 + } 28 + .row { 29 + display: flex; 30 + gap: 0.5rem; 31 + flex-wrap: wrap; 32 + margin-bottom: 0.75rem; 33 + } 34 + button { 35 + padding: 0.4rem 0.8rem; 36 + border: 1px solid currentColor; 37 + background: transparent; 38 + color: inherit; 39 + border-radius: 6px; 40 + cursor: pointer; 41 + font: inherit; 42 + } 43 + button:hover { 44 + background: rgba(0, 0, 0, 0.05); 45 + } 46 + html.dark button:hover { 47 + background: rgba(255, 255, 255, 0.08); 48 + } 49 + pre { 50 + background: rgba(0, 0, 0, 0.06); 51 + padding: 0.5rem 0.75rem; 52 + border-radius: 6px; 53 + white-space: pre-wrap; 54 + word-break: break-word; 55 + max-height: 220px; 56 + overflow: auto; 57 + font-size: 12px; 58 + } 59 + html.dark pre { 60 + background: rgba(255, 255, 255, 0.08); 61 + } 62 + .field { 63 + display: flex; 64 + flex-direction: column; 65 + gap: 0.25rem; 66 + margin-bottom: 0.5rem; 67 + } 68 + .field label { 69 + font-size: 12px; 70 + opacity: 0.7; 71 + } 72 + input, 73 + textarea { 74 + font: inherit; 75 + padding: 0.35rem 0.5rem; 76 + border-radius: 4px; 77 + border: 1px solid rgba(0, 0, 0, 0.2); 78 + background: white; 79 + color: inherit; 80 + } 81 + html.dark input, 82 + html.dark textarea { 83 + background: #27272a; 84 + border-color: rgba(255, 255, 255, 0.2); 85 + } 86 + .muted { 87 + opacity: 0.6; 88 + font-size: 12px; 89 + } 90 + </style> 91 + </head> 92 + <body> 93 + <h1>Blento Embed SDK · v0 test harness</h1> 94 + 95 + <div class="muted">Theme: <span id="theme-out">…</span></div> 96 + <div class="muted"> 97 + Ready: <span id="ready-out">pending</span> · Session: <span id="session-out">…</span> 98 + </div> 99 + 100 + <hr style="margin: 1rem 0; opacity: 0.2" /> 101 + 102 + <div class="field"> 103 + <label>Collection</label> 104 + <input id="collection" value="community.lexicon.calendar.rsvp" /> 105 + </div> 106 + <div class="field"> 107 + <label>rkey (optional for create)</label> 108 + <input id="rkey" placeholder="auto-generated if blank" /> 109 + </div> 110 + <div class="field"> 111 + <label>Record JSON</label> 112 + <textarea id="record" rows="4"> 113 + { "status": "going", "subject": { "uri": "at://did:plc:test/community.lexicon.calendar.event/abc", "cid": "bafy" } }</textarea 114 + > 115 + </div> 116 + 117 + <div class="row"> 118 + <button id="btn-create">createRecord</button> 119 + <button id="btn-put">putRecord</button> 120 + <button id="btn-delete">deleteRecord</button> 121 + <button id="btn-applywrites">applyWrites (create)</button> 122 + </div> 123 + 124 + <div class="field"> 125 + <label>Upload blob</label> 126 + <input id="file" type="file" /> 127 + <button id="btn-upload" style="align-self: start">uploadBlob</button> 128 + </div> 129 + 130 + <div class="row"> 131 + <button id="btn-resize">notifyResize(800)</button> 132 + <button id="btn-navigate">notifyNavigate('/')</button> 133 + </div> 134 + 135 + <div class="field"> 136 + <label>Last result</label> 137 + <pre id="out">—</pre> 138 + </div> 139 + 140 + <script src="/embed/v0/sdk.js"></script> 141 + <script> 142 + const $ = (id) => document.getElementById(id); 143 + const out = $('out'); 144 + 145 + function show(label, value) { 146 + out.textContent = label + '\n' + JSON.stringify(value, null, 2); 147 + } 148 + function showError(label, err) { 149 + out.textContent = 150 + label + ' [' + (err && err.code) + ']\n' + (err && err.message ? err.message : err); 151 + } 152 + 153 + function refreshSession() { 154 + const s = window.Blento.getSession(); 155 + $('session-out').textContent = s ? s.handle || s.did : 'null'; 156 + } 157 + 158 + $('theme-out').textContent = JSON.stringify(window.Blento.getTheme()); 159 + 160 + window.Blento.ready.then( 161 + () => { 162 + $('ready-out').textContent = 'ready'; 163 + refreshSession(); 164 + }, 165 + (err) => { 166 + $('ready-out').textContent = 'failed: ' + (err && err.message); 167 + } 168 + ); 169 + 170 + window.Blento.on('session', () => refreshSession()); 171 + 172 + function readInputs() { 173 + const collection = $('collection').value.trim(); 174 + const rkey = $('rkey').value.trim() || undefined; 175 + let record; 176 + try { 177 + record = JSON.parse($('record').value); 178 + } catch (e) { 179 + throw new Error('Invalid JSON in record field'); 180 + } 181 + return { collection, rkey, record }; 182 + } 183 + 184 + $('btn-create').onclick = async () => { 185 + try { 186 + const { collection, rkey, record } = readInputs(); 187 + const r = await window.Blento.createRecord({ collection, rkey, record }); 188 + show('createRecord ok', r); 189 + } catch (e) { 190 + showError('createRecord error', e); 191 + } 192 + }; 193 + 194 + $('btn-put').onclick = async () => { 195 + try { 196 + const { collection, rkey, record } = readInputs(); 197 + if (!rkey) throw new Error('rkey required for putRecord'); 198 + const r = await window.Blento.putRecord({ collection, rkey, record }); 199 + show('putRecord ok', r); 200 + } catch (e) { 201 + showError('putRecord error', e); 202 + } 203 + }; 204 + 205 + $('btn-delete').onclick = async () => { 206 + try { 207 + const { collection, rkey } = readInputs(); 208 + if (!rkey) throw new Error('rkey required for deleteRecord'); 209 + const r = await window.Blento.deleteRecord({ collection, rkey }); 210 + show('deleteRecord ok', r); 211 + } catch (e) { 212 + showError('deleteRecord error', e); 213 + } 214 + }; 215 + 216 + $('btn-applywrites').onclick = async () => { 217 + try { 218 + const { collection, record } = readInputs(); 219 + const r = await window.Blento.applyWrites({ 220 + writes: [{ $type: 'create', collection, value: record }] 221 + }); 222 + show('applyWrites ok', r); 223 + } catch (e) { 224 + showError('applyWrites error', e); 225 + } 226 + }; 227 + 228 + $('btn-upload').onclick = async () => { 229 + const file = $('file').files[0]; 230 + if (!file) { 231 + showError('uploadBlob error', new Error('Pick a file first')); 232 + return; 233 + } 234 + try { 235 + const r = await window.Blento.uploadBlob(file, { mimeType: file.type }); 236 + show('uploadBlob ok', r); 237 + } catch (e) { 238 + showError('uploadBlob error', e); 239 + } 240 + }; 241 + 242 + $('btn-resize').onclick = () => { 243 + window.Blento.notifyResize(800); 244 + show('notifyResize(800) sent', null); 245 + }; 246 + 247 + $('btn-navigate').onclick = () => { 248 + window.Blento.notifyNavigate('/'); 249 + show('notifyNavigate(/) sent', null); 250 + }; 251 + 252 + if (window.Blento.getTheme().dark) { 253 + document.documentElement.classList.add('dark'); 254 + } 255 + </script> 256 + </body> 257 + </html>